From 2dec03e7b208dc164b8d4a3194b09e417b1dc003 Mon Sep 17 00:00:00 2001 From: fy59 Date: Sat, 12 Sep 2026 18:34:53 +0200 Subject: [PATCH] feat(trainlog): add synchronized training feedback --- CHANGELOG.md | 16 + README.md | 2 +- android/app/build.gradle.kts | 1 - .../ui/SessionDraftUiInstrumentedTest.kt | 30 +- android/app/src/main/AndroidManifest.xml | 2 + .../trainlog/data/SyncCatalogInbox.kt | 30 +- .../labfytools/trainlog/data/SyncExporter.kt | 5 + .../trainlog/data/TrainlogRepository.kt | 527 +++++++++++++++++- .../trainlog/data/TrainlogTimestamp.kt | 18 + .../trainlog/model/SessionModels.kt | 40 ++ .../ui/AndroidSpeechRecognitionAdapter.kt | 54 ++ .../trainlog/ui/FeedbackDictation.kt | 89 +++ .../labfytools/trainlog/ui/FeedbackEditor.kt | 92 +++ .../trainlog/ui/SessionDetailScreen.kt | 97 +++- .../labfytools/trainlog/ui/SessionScreen.kt | 201 +++++-- .../trainlog/ui/TrainlogComponents.kt | 50 +- .../labfytools/trainlog/ui/TrainlogIcons.kt | 101 ++++ .../data/AndroidV10PlanningMigrationTest.kt | 2 +- .../AndroidV11ExerciseAliasMigrationTest.kt | 2 +- .../labfytools/trainlog/data/BodyZonesTest.kt | 2 +- .../trainlog/data/FeedbackElapsedLabelTest.kt | 23 + ...lAndroidV12MachineExerciseMigrationTest.kt | 62 ++- ...AndroidV14FeedbackRevisionMigrationTest.kt | 48 ++ .../data/TrainingFeedbackRepositoryTest.kt | 125 +++++ .../data/TrainlogRepositoryDraftTest.kt | 4 +- .../ui/AndroidSpeechRecognitionIntentTest.kt | 25 + .../ui/DraftFeedbackUiReadbackTest.kt | 74 +++ .../ui/FeedbackDictationControllerTest.kt | 47 ++ .../trainlog/ui/FeedbackEditorVoiceUiTest.kt | 51 ++ docs/android.md | 29 +- docs/architecture.md | 6 +- docs/current_state.md | 6 +- docs/database.md | 28 +- .../training_feedback_v1_ux_safety_audit.md | 39 ++ docs/roadmap.md | 6 + docs/sync_exchange.md | 6 +- docs/tests.md | 17 + docs/training_feedback.md | 108 ++++ docs/tui.md | 10 + tests/test_training_feedback_sync.py | 33 ++ tools/export_equipment_associations.py | 2 +- tools/export_equipment_definitions.py | 2 +- tools/export_exercise_aliases.py | 2 +- tools/export_exercise_body_zones.py | 2 +- tools/export_pc_catalog.py | 2 +- tools/export_pc_mobile.py | 2 +- tools/export_training_feedback.py | 31 ++ tools/import_equipment_associations.py | 2 +- tools/import_equipment_definitions.py | 2 +- tools/import_exercise_aliases.py | 4 +- tools/import_exercise_body_zones.py | 2 +- tools/import_mobile_export.py | 4 +- tools/import_training_feedback.py | 86 +++ tui/include/trainlog/database.h | 27 +- tui/include/trainlog/timeutil.h | 8 + tui/meson.build | 22 + tui/src/database.c | 223 +++++++- tui/src/sync.c | 56 ++ tui/src/timeutil.c | 30 + tui/src/tui.c | 153 ++++- tui/tests/test_body_zones.c | 2 +- tui/tests/test_feedback_correction.c | 137 +++++ tui/tests/test_feedback_labels.c | 12 + tui/tests/test_schema_v7_migration.c | 10 +- tui/tests/test_schema_v9_migration.c | 2 +- tui/tests/test_tui_workflows.c | 30 + 66 files changed, 2811 insertions(+), 152 deletions(-) create mode 100644 android/app/src/main/java/com/labfytools/trainlog/ui/AndroidSpeechRecognitionAdapter.kt create mode 100644 android/app/src/main/java/com/labfytools/trainlog/ui/FeedbackDictation.kt create mode 100644 android/app/src/main/java/com/labfytools/trainlog/ui/FeedbackEditor.kt create mode 100644 android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogIcons.kt create mode 100644 android/app/src/test/java/com/labfytools/trainlog/data/FeedbackElapsedLabelTest.kt create mode 100644 android/app/src/test/java/com/labfytools/trainlog/data/RealAndroidV14FeedbackRevisionMigrationTest.kt create mode 100644 android/app/src/test/java/com/labfytools/trainlog/data/TrainingFeedbackRepositoryTest.kt create mode 100644 android/app/src/test/java/com/labfytools/trainlog/ui/AndroidSpeechRecognitionIntentTest.kt create mode 100644 android/app/src/test/java/com/labfytools/trainlog/ui/DraftFeedbackUiReadbackTest.kt create mode 100644 android/app/src/test/java/com/labfytools/trainlog/ui/FeedbackDictationControllerTest.kt create mode 100644 android/app/src/test/java/com/labfytools/trainlog/ui/FeedbackEditorVoiceUiTest.kt create mode 100644 docs/reviews/training_feedback_v1_ux_safety_audit.md create mode 100644 docs/training_feedback.md create mode 100644 tests/test_training_feedback_sync.py create mode 100644 tools/export_training_feedback.py create mode 100644 tools/import_training_feedback.py create mode 100644 tui/tests/test_feedback_correction.c create mode 100644 tui/tests/test_feedback_labels.c diff --git a/CHANGELOG.md b/CHANGELOG.md index ce8b49f..26baf3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## Unreleased — Training feedback UX and safety + +- Added additive schema v15 immutable feedback revisions and Training Feedback + V2 with V1 import compatibility. +- Made saved exercise feedback and session follow-ups correctable on Android + while preserving root identity, observation time, H+ placement and history. +- Preserved occurrence `entry_id` and feedback through draft edit, + reconstruction, finalization and completed-session correction. +- Added explicit destructive confirmation to the reachable Android and TUI + session removal/abandon paths. + ## Unreleased — MACHINE_EXERCISE_MODEL_V1 - Add desktop and Android schema v13 for machine-specific exercise metadata. @@ -353,3 +364,8 @@ direct measurements. Android remains capture-only for this feature. - Hid `SESSION_GENERATOR_V1` from normal UI pending V2; manual `%MAX` entry is unchanged. - Added versioned canonical exercise display names, stale-peer-name convergence, and the STATS_V1 query-contract preparation (no analytics implementation). +# Unreleased + +- Add `TRAINING_FEEDBACK_V1`: schema v14 append-only exercise observations and + session follow-ups, Android French voice/manual capture with no audio storage, + bidirectional strict companion sync, H+ timelines, and TUI read-only display. diff --git a/README.md b/README.md index bb23098..c3fad2e 100644 --- a/README.md +++ b/README.md @@ -205,7 +205,7 @@ export or desktop synchronization as completed sessions. Schema migrations are additive and preserve existing capture data. See [Android behavior](docs/android.md) and [validation](docs/tests.md). -The current Android schema is v13. Its additive v4 -> v13 chain adds the shared +The current Android schema is v14. Its additive v4 -> v14 chain adds the shared equipment catalogue, per-occurrence equipment links, durable occurrence identities, custom-equipment definition support, explicit MAX results and stable-source Test max resumption, then direct primary/secondary body-zone diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 1bd62ff..2f93981 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -70,7 +70,6 @@ dependencies { ) implementation("androidx.compose.material3:material3") - implementation( "androidx.compose.ui:ui" ) diff --git a/android/app/src/androidTest/java/com/labfytools/trainlog/ui/SessionDraftUiInstrumentedTest.kt b/android/app/src/androidTest/java/com/labfytools/trainlog/ui/SessionDraftUiInstrumentedTest.kt index e87a4ba..dbcb790 100644 --- a/android/app/src/androidTest/java/com/labfytools/trainlog/ui/SessionDraftUiInstrumentedTest.kt +++ b/android/app/src/androidTest/java/com/labfytools/trainlog/ui/SessionDraftUiInstrumentedTest.kt @@ -5,6 +5,7 @@ import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo @@ -93,19 +94,19 @@ class SessionDraftUiInstrumentedTest { .performClick() compose.onNodeWithTag("session-set-0-reps").performScrollTo().assertTextEquals("4") compose.onNodeWithTag("session-set-2-reps").performScrollTo().assertTextEquals("6") - compose.onNodeWithText( - "Retirer Test UI" + compose.onNodeWithContentDescription( + "Supprimer cet exercice" ).performScrollTo() .assertIsDisplayed() } @Test fun confirmedDiscardRemovesResumeWithoutHistory() { - compose.onNodeWithText( + compose.onNodeWithContentDescription( "Supprimer la séance en cours" ).performClick() compose.onNodeWithText( - "Confirmer la suppression" + "Abandonner la séance en cours ?" ).assertIsDisplayed() compose.onNodeWithText( "Annuler" @@ -114,13 +115,13 @@ class SessionDraftUiInstrumentedTest { "Reprendre la séance en cours" ).assertIsDisplayed() - compose.onNodeWithText( + compose.onNodeWithContentDescription( "Supprimer la séance en cours" ).performClick() compose.onNodeWithText( - "Confirmer la suppression" + "Abandonner la séance en cours ?" ).assertIsDisplayed() - .performClick() + compose.onNodeWithText("Abandonner").performClick() compose.onNodeWithText( "Reprendre la séance en cours" ).assertDoesNotExist() @@ -229,7 +230,10 @@ class SessionDraftUiInstrumentedTest { compose.onNodeWithTag("session-set-1-reps").performTextInput("9") compose.onNodeWithTag("session-set-1-weight") .performScrollTo().performTextInput("32,5") - compose.onNodeWithText("Supprimer la série 1").performScrollTo().performClick() + compose.onNodeWithTag("delete-set-1").performScrollTo().performClick() + compose.onNodeWithText("Supprimer la série 1 ?").assertIsDisplayed() + compose.onNodeWithTag("session-set-0-reps").assertTextEquals("4") + compose.onNodeWithText("Supprimer").performClick() compose.onNodeWithText("Ajouter une série").performScrollTo().performClick() compose.onNodeWithTag("session-set-0-reps").performScrollTo().assertTextEquals("9") @@ -243,6 +247,16 @@ class SessionDraftUiInstrumentedTest { compose.onNodeWithTag("session-set-3-reps").performScrollTo().assertTextEquals("") } + @Test + fun modifierOpensPrefilledEditorAndKeepsOccurrenceIdentity() { + compose.onNodeWithText("Reprendre la séance en cours").performClick() + compose.onNodeWithText("Modifier Test UI").performScrollTo().performClick() + compose.onNodeWithText("Modification en cours — faites défiler jusqu’à SAISIE — Test UI.") + .performScrollTo().assertIsDisplayed() + compose.onNodeWithTag("session-set-0-reps").performScrollTo().assertTextEquals("4") + compose.onNodeWithTag("session-set-2-reps").performScrollTo().assertTextEquals("6") + } + @Test fun selectingAnExerciseUsesItsIdentityAndAllowsASecondPassage() { resumeAndOpenExerciseSearch() diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 2f85ef1..d95d6be 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,7 @@ + + when (val equipment = importPcEquipmentAssociations(directory)) { null -> when (val bodyZones = importPcBodyZones(directory)) { - null -> CatalogInboxResult.Imported( - imported = result.imported, - reconciled = result.reconciled, - skipped = result.skipped, - ) + null -> when (val feedback = importTrainingFeedback(directory)) { + null -> CatalogInboxResult.Imported( + imported = result.imported, + reconciled = result.reconciled, + skipped = result.skipped, + ) + else -> CatalogInboxResult.Error(feedback) + } else -> CatalogInboxResult.Error(bodyZones) } else -> CatalogInboxResult.Error(equipment) @@ -196,6 +199,23 @@ class SyncCatalogInbox( } catch (error: Exception) { error.message ?: "Import séances impossible." } } + private fun importTrainingFeedback(directory: DocumentFile): String? { + /* V2 is authoritative for revision history. V1 remains a fallback for + * an older peer and can only add deterministic initial revisions. */ + val file = directory.findFile("trainlog-training-feedback-v2.json") + ?: directory.findFile("trainlog-training-feedback-v1.json") ?: return null + return try { + val json = appContext.contentResolver.openInputStream(file.uri)?.bufferedReader(Charsets.UTF_8) + ?.use { it.readText() } ?: return "Lecture des ressentis impossible." + when (val result = repository.applyTrainingFeedbackJson(json)) { + is TrainingFeedbackImportResult.Applied -> null + is TrainingFeedbackImportResult.Invalid -> result.message + is TrainingFeedbackImportResult.Conflict -> "Conflit de ressenti : ${result.stableId}" + TrainingFeedbackImportResult.DatabaseError -> "Erreur base locale ressentis." + } + } catch (error: Exception) { error.message ?: "Import des ressentis impossible." } + } + /** Test seam for the real filename-priority boundary; production uses the same method. */ internal fun importPcSessionsFromDirectoryForTest(directory: DocumentFile): String? = importPcSessions(directory) diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/SyncExporter.kt b/android/app/src/main/java/com/labfytools/trainlog/data/SyncExporter.kt index 1cc0366..20af90a 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/SyncExporter.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/SyncExporter.kt @@ -48,6 +48,7 @@ class SyncExporter( val associationsJson: String val bodyZonesJson: String val aliasesJson: String + val feedbackJson: String try { definitionsJson = repository.buildEquipmentDefinitionsJson() /* V3 is the authoritative mobile session exchange. V1/V2 remain @@ -56,6 +57,7 @@ class SyncExporter( associationsJson = repository.buildEquipmentAssociationsJson() bodyZonesJson = repository.buildExerciseBodyZonesJson() aliasesJson = repository.buildExerciseAliasesJson() + feedbackJson = repository.buildTrainingFeedbackJson() } catch (error: Exception) { return SyncExportResult.Error( error.message ?: "Préparation de l'export impossible.", @@ -200,6 +202,9 @@ class SyncExporter( "alias exercice", ) if (aliasesError != null) return SyncExportResult.Error(aliasesError) + val feedbackError = writeJsonCompanion( + "trainlog-training-feedback-v2.json", feedbackJson, "ressentis d’entraînement") + if (feedbackError != null) return SyncExportResult.Error(feedbackError) /* CONTRACT: publication, not JSON construction, establishes the * common sync ancestor. Applying the exact local snapshot can only * record equal baselines; the strict reconciler never unions zones. */ diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt index 2e249ef..e72bb6b 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt @@ -14,6 +14,8 @@ import com.labfytools.trainlog.model.BodyObservationSummary import com.labfytools.trainlog.model.ExerciseDataFields import com.labfytools.trainlog.model.ExerciseProfile import com.labfytools.trainlog.model.ExerciseEditInput +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.RecordingMode @@ -24,6 +26,7 @@ import com.labfytools.trainlog.model.SessionSummary import com.labfytools.trainlog.model.SessionDetail import com.labfytools.trainlog.model.SessionExerciseDetail import com.labfytools.trainlog.model.SessionExercisePlan +import com.labfytools.trainlog.model.SessionFollowUp import com.labfytools.trainlog.model.SessionLoadMode import com.labfytools.trainlog.model.SessionSetDraft import com.labfytools.trainlog.model.SessionType @@ -180,6 +183,20 @@ sealed interface SaveSessionResult { SaveSessionResult } +sealed interface SaveFeedbackResult { + data class Saved(val stableId: String) : SaveFeedbackResult + data class Invalid(val message: String) : SaveFeedbackResult + data class DatabaseError(val message: String) : SaveFeedbackResult +} + +sealed interface TrainingFeedbackImportResult { + data class Applied(val exerciseAdded: Int, val exerciseSkipped: Int, + val followupsAdded: Int, val followupsSkipped: Int) : TrainingFeedbackImportResult + data class Invalid(val message: String) : TrainingFeedbackImportResult + data class Conflict(val stableId: String) : TrainingFeedbackImportResult + data object DatabaseError : TrainingFeedbackImportResult +} + sealed interface ActiveDraftLoadResult { data class Loaded( val draft: ActiveSessionDraft, @@ -1541,6 +1558,7 @@ class TrainlogRepository( val startedAt = OffsetDateTime.now() .toString() + val endedAt = OffsetDateTime.now().toString() db.beginTransaction() @@ -1562,6 +1580,7 @@ class TrainlogRepository( draft.sessionType .wireValue ) + put("ended_at", endedAt) } val sessionRowId = @@ -3815,7 +3834,8 @@ class TrainlogRepository( s.session_id, s.started_at, s.session_type, - COUNT(se.id) + COUNT(se.id), + s.ended_at FROM sessions AS s LEFT JOIN session_exercises AS se ON se.session_row_id = s.id @@ -3835,9 +3855,8 @@ class TrainlogRepository( exerciseCount = cursor.getInt(3), sessionType = - SessionType.fromWire( - cursor.getString(2) - ), + SessionType.fromWire(cursor.getString(2)), + endedAt = cursor.optionalText(4), ) } } ?: return null @@ -4076,12 +4095,328 @@ class TrainlogRepository( } } + val feedbackByEntry = listExerciseFeedback(sessionId).groupBy { it.entryId } return SessionDetail( summary = summary, - exercises = exercises, + exercises = exercises.map { it.copy(feedback = feedbackByEntry[it.entryId].orEmpty()) }, + followUps = listSessionFollowUps(sessionId), ) } + /** Android-only root creation API. Revisions are immutable; raw_text on + * the root is only the transactionally maintained current-text cache. */ + fun saveExerciseFeedback(sessionId: String, entryId: String, rawText: String, + observedAt: String = OffsetDateTime.now().toString()): SaveFeedbackResult { + val text = rawText.trim() + if (!validFeedbackText(text) || TrainlogTimestamp.parse(observedAt) == null) + return SaveFeedbackResult.Invalid("Le ressenti doit contenir au plus 8192 octets UTF-8.") + val stableId = "fb_" + UUID.randomUUID().toString() + return try { + val db = database.writableDatabase + val row = db.rawQuery( + "SELECT se.id FROM session_exercises se JOIN sessions s ON s.id=se.session_row_id WHERE s.session_id=? AND se.entry_id=?;", + arrayOf(sessionId, entryId), + ).use { if (it.moveToFirst()) it.getLong(0) else null } + ?: return SaveFeedbackResult.Invalid("Occurrence de séance introuvable.") + db.beginTransaction() + db.insertOrThrow("exercise_feedback", null, ContentValues().apply { + put("feedback_id", stableId); put("session_exercise_row_id", row) + put("observed_at", observedAt); put("raw_text", text) + }) + insertInitialRevision(db, "exercise_feedback_revisions", "feedback_id", stableId, observedAt, text) + db.setTransactionSuccessful(); db.endTransaction() + SaveFeedbackResult.Saved(stableId) + } catch (error: Exception) { if (database.writableDatabase.inTransaction()) database.writableDatabase.endTransaction(); SaveFeedbackResult.DatabaseError(error.message ?: "Enregistrement impossible.") } + } + + /** Persist feedback immediately against the durable active-draft + * occurrence. The stable ID is retained when the session is finalized. */ + fun saveDraftExerciseFeedback(entryId: String, rawText: String, + observedAt: String = OffsetDateTime.now().toString()): SaveFeedbackResult { + val text = rawText.trim() + if (!validFeedbackText(text) || TrainlogTimestamp.parse(observedAt) == null) + return SaveFeedbackResult.Invalid("Le ressenti doit contenir au plus 8192 octets UTF-8.") + val stableId = "fb_" + UUID.randomUUID().toString() + return try { + val db = database.writableDatabase + val row = db.rawQuery( + "SELECT de.id FROM draft_session_exercises de JOIN active_session_draft d ON d.id=de.draft_id " + + "WHERE d.id=? AND de.entry_id=?;", + arrayOf(ACTIVE_DRAFT_ID.toString(), entryId), + ).use { if (it.moveToFirst()) it.getLong(0) else null } + ?: return SaveFeedbackResult.Invalid("Occurrence du brouillon introuvable.") + db.beginTransaction() + db.insertOrThrow("draft_exercise_feedback", null, ContentValues().apply { + put("feedback_id", stableId); put("draft_session_exercise_row_id", row) + put("observed_at", observedAt); put("raw_text", text) + }) + insertInitialRevision(db, "draft_exercise_feedback_revisions", "feedback_id", stableId, observedAt, text) + db.setTransactionSuccessful(); db.endTransaction() + SaveFeedbackResult.Saved(stableId) + } catch (error: Exception) { + if (database.writableDatabase.inTransaction()) database.writableDatabase.endTransaction() + SaveFeedbackResult.DatabaseError(error.message ?: "Enregistrement impossible.") + } + } + + fun listDraftExerciseFeedback(entryId: String): List = buildList { + database.readableDatabase.rawQuery( + "SELECT f.feedback_id,de.entry_id,e.exercise_id,f.observed_at,f.raw_text," + + "(SELECT COUNT(*) FROM draft_exercise_feedback_revisions r WHERE r.feedback_id=f.feedback_id) " + + "FROM draft_exercise_feedback f JOIN draft_session_exercises de " + + "ON de.id=f.draft_session_exercise_row_id JOIN exercises e ON e.id=de.exercise_row_id " + + "WHERE de.draft_id=? AND de.entry_id=?;", + arrayOf(ACTIVE_DRAFT_ID.toString(), entryId), + ).use { c -> while (c.moveToNext()) add(DraftExerciseFeedback( + c.getString(0), c.getString(1), c.getString(2), c.getString(3), c.getString(4), c.getInt(5) > 1)) } + }.sortedWith { a,b -> TrainlogTimestamp.parse(a.observedAt)!!.compareTo(TrainlogTimestamp.parse(b.observedAt)!!) + .takeIf { it != 0 } ?: TrainlogTimestamp.compareIds(a.feedbackId,b.feedbackId) } + + fun saveSessionFollowUp(sessionId: String, rawText: String, + observedAt: String = OffsetDateTime.now().toString()): SaveFeedbackResult { + val text = rawText.trim() + if (!validFeedbackText(text) || TrainlogTimestamp.parse(observedAt) == null) + return SaveFeedbackResult.Invalid("Le suivi doit contenir au plus 8192 octets UTF-8.") + val stableId = "fu_" + UUID.randomUUID().toString() + return try { + val db = database.writableDatabase + val row = db.rawQuery("SELECT id FROM sessions WHERE session_id=?;", arrayOf(sessionId)) + .use { if (it.moveToFirst()) it.getLong(0) else null } + ?: return SaveFeedbackResult.Invalid("Séance introuvable.") + db.beginTransaction() + db.insertOrThrow("session_followups", null, ContentValues().apply { + put("followup_id", stableId); put("session_row_id", row) + put("observed_at", observedAt); put("raw_text", text) + }) + insertInitialRevision(db, "session_followup_revisions", "followup_id", stableId, observedAt, text) + db.setTransactionSuccessful(); db.endTransaction() + SaveFeedbackResult.Saved(stableId) + } catch (error: Exception) { if (database.writableDatabase.inTransaction()) database.writableDatabase.endTransaction(); SaveFeedbackResult.DatabaseError(error.message ?: "Enregistrement impossible.") } + } + + private fun insertInitialRevision(db: SQLiteDatabase, table: String, parentColumn: String, + rootId: String, createdAt: String, rawText: String) { + db.insertOrThrow(table, null, ContentValues().apply { + put("revision_id", "fr0_$rootId"); put(parentColumn, rootId) + put("created_at", createdAt); put("raw_text", rawText) + }) + } + + private fun saveFeedbackRevision(table: String, parentColumn: String, rootTable: String, + rootIdColumn: String, rootId: String, rawText: String, + createdAt: String = OffsetDateTime.now().toString()): SaveFeedbackResult { + val text = rawText.trim() + if (!validFeedbackText(text) || TrainlogTimestamp.parse(createdAt) == null) + return SaveFeedbackResult.Invalid("Le texte doit contenir au plus 8192 octets UTF-8.") + val revisionId = "fr_" + UUID.randomUUID().toString() + val db = database.writableDatabase + return try { + db.beginTransaction() + val changed = db.update(rootTable, ContentValues().apply { put("raw_text", text) }, + "$rootIdColumn=?", arrayOf(rootId)) + if (changed != 1) throw IllegalArgumentException("Ressenti introuvable.") + db.insertOrThrow(table, null, ContentValues().apply { + put("revision_id", revisionId); put(parentColumn, rootId) + put("created_at", createdAt); put("raw_text", text) + }) + db.setTransactionSuccessful(); SaveFeedbackResult.Saved(rootId) + } catch (error: Exception) { + SaveFeedbackResult.DatabaseError(error.message ?: "Correction impossible.") + } finally { if (db.inTransaction()) db.endTransaction() } + } + + fun reviseExerciseFeedback(feedbackId: String, rawText: String): SaveFeedbackResult = + saveFeedbackRevision("exercise_feedback_revisions", "feedback_id", "exercise_feedback", "feedback_id", feedbackId, rawText) + + fun reviseDraftExerciseFeedback(feedbackId: String, rawText: String): SaveFeedbackResult = + saveFeedbackRevision("draft_exercise_feedback_revisions", "feedback_id", "draft_exercise_feedback", "feedback_id", feedbackId, rawText) + + fun reviseSessionFollowUp(followupId: String, rawText: String): SaveFeedbackResult = + saveFeedbackRevision("session_followup_revisions", "followup_id", "session_followups", "followup_id", followupId, rawText) + + fun listExerciseFeedback(sessionId: String): List = buildList { + database.readableDatabase.rawQuery( + "SELECT f.feedback_id,s.session_id,se.entry_id,e.exercise_id,f.observed_at,f.raw_text," + + "(SELECT COUNT(*) FROM exercise_feedback_revisions r WHERE r.feedback_id=f.feedback_id) " + + "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 " + + "WHERE s.session_id=?;", arrayOf(sessionId), + ).use { c -> while (c.moveToNext()) add(ExerciseFeedback(c.getString(0),c.getString(1),c.getString(2),c.getString(3),c.getString(4),c.getString(5),c.getInt(6)>1)) } + }.sortedWith { a,b -> TrainlogTimestamp.parse(a.observedAt)!!.compareTo(TrainlogTimestamp.parse(b.observedAt)!!) + .takeIf { it != 0 } ?: TrainlogTimestamp.compareIds(a.feedbackId,b.feedbackId) } + + fun listSessionFollowUps(sessionId: String): List = buildList { + database.readableDatabase.rawQuery( + "SELECT f.followup_id,s.session_id,f.observed_at,f.raw_text," + + "(SELECT COUNT(*) FROM session_followup_revisions r WHERE r.followup_id=f.followup_id) FROM session_followups f " + + "JOIN sessions s ON s.id=f.session_row_id WHERE s.session_id=?;", arrayOf(sessionId), + ).use { c -> while (c.moveToNext()) add(SessionFollowUp(c.getString(0),c.getString(1),c.getString(2),c.getString(3),c.getInt(4)>1)) } + }.sortedWith { a,b -> TrainlogTimestamp.parse(a.observedAt)!!.compareTo(TrainlogTimestamp.parse(b.observedAt)!!) + .takeIf { it != 0 } ?: TrainlogTimestamp.compareIds(a.followupId,b.followupId) } + + private fun validFeedbackText(text: String): Boolean = text.isNotBlank() && + text.toByteArray(Charsets.UTF_8).size <= MAX_FEEDBACK_UTF8_BYTES + + /** Direction-neutral V2 companion: roots and every immutable revision. */ + fun buildTrainingFeedbackJson(): String { + val root = JSONObject().put("format", "trainlog-training-feedback").put("version", 2) + .put("generated_at", OffsetDateTime.now().toString()) + val exercise = JSONArray() + database.readableDatabase.rawQuery( + "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 " + + "ORDER BY f.observed_at,f.feedback_id;", null, + ).use { c -> while (c.moveToNext()) { + val revisions = JSONArray() + database.readableDatabase.rawQuery( + "SELECT revision_id,created_at,raw_text FROM exercise_feedback_revisions WHERE feedback_id=?;", + arrayOf(c.getString(0)), + ).use { r -> while (r.moveToNext()) revisions.put(JSONObject().put("revision_id",r.getString(0)) + .put("created_at",r.getString(1)).put("raw_text",r.getString(2))) } + exercise.put(JSONObject().put("feedback_id",c.getString(0)).put("session_id",c.getString(1)) + .put("entry_id",c.getString(2)).put("exercise_id",c.getString(3)).put("observed_at",c.getString(4)) + .put("revisions",revisions)) + } } + val followups = JSONArray() + database.readableDatabase.rawQuery( + "SELECT f.followup_id,s.session_id,f.observed_at FROM session_followups f " + + "JOIN sessions s ON s.id=f.session_row_id ORDER BY f.observed_at,f.followup_id;", null, + ).use { c -> while (c.moveToNext()) { + val revisions = JSONArray() + database.readableDatabase.rawQuery( + "SELECT revision_id,created_at,raw_text FROM session_followup_revisions WHERE followup_id=?;", + arrayOf(c.getString(0)), + ).use { r -> while (r.moveToNext()) revisions.put(JSONObject().put("revision_id",r.getString(0)) + .put("created_at",r.getString(1)).put("raw_text",r.getString(2))) } + followups.put(JSONObject().put("followup_id",c.getString(0)).put("session_id",c.getString(1)) + .put("observed_at",c.getString(2)).put("revisions",revisions)) + } } + return root.put("exercise_feedback",exercise).put("session_followups",followups).toString() + } + + fun applyTrainingFeedbackJson(json: String): TrainingFeedbackImportResult { + if (json.toByteArray(Charsets.UTF_8).size > 40 * 1024 * 1024) + return TrainingFeedbackImportResult.Invalid("Compagnon de ressentis trop volumineux.") + val root = try { JSONObject(json) } catch (_: Exception) { + return TrainingFeedbackImportResult.Invalid("JSON de ressentis invalide.") + } + if (root.keys().asSequence().toSet() != setOf("format","version","generated_at","exercise_feedback","session_followups") || + root.optString("format") != "trainlog-training-feedback" || root.optInt("version",-1) !in 1..2 || + TrainlogTimestamp.parse(root.optString("generated_at")) == null) + return TrainingFeedbackImportResult.Invalid("Contrat trainlog-training-feedback v1/v2 invalide.") + if (root.optInt("version") == 2) return applyTrainingFeedbackV2(root) + val exercise = root.optJSONArray("exercise_feedback") + ?: return TrainingFeedbackImportResult.Invalid("exercise_feedback absent.") + val followups = root.optJSONArray("session_followups") + ?: return TrainingFeedbackImportResult.Invalid("session_followups absent.") + if (exercise.length() > MAX_FEEDBACK_RECORDS || followups.length() > MAX_FEEDBACK_RECORDS) + return TrainingFeedbackImportResult.Invalid("Trop d’observations dans le compagnon.") + val db = database.writableDatabase + var ea=0; var es=0; var fa=0; var fs=0 + db.beginTransaction() + try { + for (index in 0 until exercise.length()) { + val item = exercise.optJSONObject(index) ?: throw IllegalArgumentException("Observation exercice invalide.") + val keys = setOf("feedback_id","session_id","entry_id","exercise_id","observed_at","raw_text") + if (item.keys().asSequence().toSet()!=keys) throw IllegalArgumentException("Champs observation exercice invalides.") + val id=item.optString("feedback_id"); val sid=item.optString("session_id"); val entry=item.optString("entry_id") + val claimed=item.optString("exercise_id"); val at=item.optString("observed_at"); val text=item.optString("raw_text") + if (!stableFeedbackId(id,"fb_") || TrainlogTimestamp.parse(at)==null || !validFeedbackText(text)) + throw IllegalArgumentException("Observation exercice mal formée.") + val occurrence = db.rawQuery("SELECT se.id,e.exercise_id FROM session_exercises se JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id WHERE s.session_id=? AND se.entry_id=?;",arrayOf(sid,entry)) + .use { c -> if(c.moveToFirst()) c.getLong(0) to c.getString(1) else null } + ?: throw IllegalArgumentException("Séance/entrée référencée introuvable.") + val canonicalClaim = canonicalExerciseId(db, claimed) + ?: throw IllegalArgumentException("Exercice référencé introuvable.") + if (canonicalClaim != occurrence.second) throw IllegalArgumentException("Exercice incompatible avec l’occurrence.") + val existing = db.rawQuery("SELECT session_exercise_row_id,observed_at FROM exercise_feedback WHERE feedback_id=?;",arrayOf(id)) + .use { c -> if(c.moveToFirst()) c.getLong(0) to c.getString(1) else null } + if(existing==null){ db.insertOrThrow("exercise_feedback",null,ContentValues().apply{put("feedback_id",id);put("session_exercise_row_id",occurrence.first);put("observed_at",at);put("raw_text",text)});insertInitialRevision(db,"exercise_feedback_revisions","feedback_id",id,at,text);ea++ } + else if(existing==occurrence.first to at) es++ else { db.endTransaction(); return TrainingFeedbackImportResult.Conflict(id) } + } + for(index in 0 until followups.length()) { + val item=followups.optJSONObject(index)?:throw IllegalArgumentException("Suivi invalide.") + if(item.keys().asSequence().toSet()!=setOf("followup_id","session_id","observed_at","raw_text")) throw IllegalArgumentException("Champs suivi invalides.") + val id=item.optString("followup_id");val sid=item.optString("session_id");val at=item.optString("observed_at");val text=item.optString("raw_text") + if(!stableFeedbackId(id,"fu_")||TrainlogTimestamp.parse(at)==null||!validFeedbackText(text)) throw IllegalArgumentException("Suivi mal formé.") + val row=db.rawQuery("SELECT id FROM sessions WHERE session_id=?;",arrayOf(sid)).use{if(it.moveToFirst())it.getLong(0) else null} + ?:throw IllegalArgumentException("Séance référencée introuvable.") + val old=db.rawQuery("SELECT session_row_id,observed_at FROM session_followups WHERE followup_id=?;",arrayOf(id)).use{c->if(c.moveToFirst())c.getLong(0) to c.getString(1)else null} + if(old==null){db.insertOrThrow("session_followups",null,ContentValues().apply{put("followup_id",id);put("session_row_id",row);put("observed_at",at);put("raw_text",text)});insertInitialRevision(db,"session_followup_revisions","followup_id",id,at,text);fa++} + else if(old==row to at)fs++ else {db.endTransaction();return TrainingFeedbackImportResult.Conflict(id)} + } + db.setTransactionSuccessful() + return TrainingFeedbackImportResult.Applied(ea,es,fa,fs) + } catch(error: IllegalArgumentException) { return TrainingFeedbackImportResult.Invalid(error.message?:"Compagnon invalide.") } + catch(_: Exception){return TrainingFeedbackImportResult.DatabaseError} + finally { if(db.inTransaction()) db.endTransaction() } + } + + private fun applyTrainingFeedbackV2(root: JSONObject): TrainingFeedbackImportResult { + val exercise = root.optJSONArray("exercise_feedback") ?: return TrainingFeedbackImportResult.Invalid("exercise_feedback absent.") + val followups = root.optJSONArray("session_followups") ?: return TrainingFeedbackImportResult.Invalid("session_followups absent.") + if (exercise.length() > MAX_FEEDBACK_RECORDS || followups.length() > MAX_FEEDBACK_RECORDS) + return TrainingFeedbackImportResult.Invalid("Trop d’observations dans le compagnon.") + val db=database.writableDatabase;var ea=0;var es=0;var fa=0;var fs=0 + fun mergeRevisions(item: JSONObject, rootId: String, table: String, parentColumn: String, + rootTable: String, rootColumn: String) { + val revisions=item.optJSONArray("revisions") ?: throw IllegalArgumentException("Révisions absentes.") + if(revisions.length()==0 || revisions.length()>MAX_FEEDBACK_RECORDS) throw IllegalArgumentException("Révisions hors borne.") + val seen=mutableSetOf() + for(i in 0 until revisions.length()) { + val revision=revisions.optJSONObject(i)?:throw IllegalArgumentException("Révision invalide.") + if(revision.keys().asSequence().toSet()!=setOf("revision_id","created_at","raw_text")) throw IllegalArgumentException("Champs révision invalides.") + val id=revision.optString("revision_id");val at=revision.optString("created_at");val text=revision.optString("raw_text") + if((!stableFeedbackId(id,"fr_") && id!="fr0_$rootId") || !seen.add(id) || TrainlogTimestamp.parse(at)==null || !validFeedbackText(text)) throw IllegalArgumentException("Révision mal formée.") + val old=db.rawQuery("SELECT $parentColumn,created_at,raw_text FROM $table WHERE revision_id=?",arrayOf(id)).use{c->if(c.moveToFirst())Triple(c.getString(0),c.getString(1),c.getString(2))else null} + val expected=Triple(rootId,at,text) + if(old==null) db.insertOrThrow(table,null,ContentValues().apply{put("revision_id",id);put(parentColumn,rootId);put("created_at",at);put("raw_text",text)}) + else if(old!=expected) throw SQLiteConstraintException("HARD CONFLICT $id") + } + val current=db.rawQuery("SELECT revision_id,created_at,raw_text FROM $table WHERE $parentColumn=?",arrayOf(rootId)).use{c->buildList{while(c.moveToNext())add(Triple(c.getString(0),c.getString(1),c.getString(2)))}} + .maxWithOrNull{a,b->TrainlogTimestamp.parse(a.second)!!.compareTo(TrainlogTimestamp.parse(b.second)!!).takeIf{it!=0}?:TrainlogTimestamp.compareIds(a.first,b.first)}!! + db.update(rootTable,ContentValues().apply{put("raw_text",current.third)},"$rootColumn=?",arrayOf(rootId)) + } + db.beginTransaction() + try { + for(i in 0 until exercise.length()) { + val item=exercise.optJSONObject(i)?:throw IllegalArgumentException("Observation exercice invalide.") + if(item.keys().asSequence().toSet()!=setOf("feedback_id","session_id","entry_id","exercise_id","observed_at","revisions"))throw IllegalArgumentException("Champs observation invalides.") + val id=item.optString("feedback_id");val at=item.optString("observed_at") + if(!stableFeedbackId(id,"fb_")||TrainlogTimestamp.parse(at)==null)throw IllegalArgumentException("Observation mal formée.") + val occurrence=db.rawQuery("SELECT se.id,e.exercise_id FROM session_exercises se JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id WHERE s.session_id=? AND se.entry_id=?",arrayOf(item.optString("session_id"),item.optString("entry_id"))).use{c->if(c.moveToFirst())c.getLong(0) to c.getString(1)else null}?:throw IllegalArgumentException("Occurrence introuvable.") + if(canonicalExerciseId(db,item.optString("exercise_id"))!=occurrence.second)throw IllegalArgumentException("Exercice incompatible.") + val old=db.rawQuery("SELECT session_exercise_row_id,observed_at FROM exercise_feedback WHERE feedback_id=?",arrayOf(id)).use{c->if(c.moveToFirst())c.getLong(0) to c.getString(1)else null} + val revisions=item.getJSONArray("revisions");val seed=revisions.optJSONObject(0)?.optString("raw_text")?:throw IllegalArgumentException("Révision absente.") + if(old==null){db.insertOrThrow("exercise_feedback",null,ContentValues().apply{put("feedback_id",id);put("session_exercise_row_id",occurrence.first);put("observed_at",at);put("raw_text",seed)});ea++} + else if(old==occurrence.first to at)es++ else throw SQLiteConstraintException("HARD CONFLICT $id") + mergeRevisions(item,id,"exercise_feedback_revisions","feedback_id","exercise_feedback","feedback_id") + } + for(i in 0 until followups.length()) { + val item=followups.optJSONObject(i)?:throw IllegalArgumentException("Suivi invalide.") + if(item.keys().asSequence().toSet()!=setOf("followup_id","session_id","observed_at","revisions"))throw IllegalArgumentException("Champs suivi invalides.") + val id=item.optString("followup_id");val at=item.optString("observed_at") + if(!stableFeedbackId(id,"fu_")||TrainlogTimestamp.parse(at)==null)throw IllegalArgumentException("Suivi mal formé.") + val row=db.rawQuery("SELECT id FROM sessions WHERE session_id=?",arrayOf(item.optString("session_id"))).use{if(it.moveToFirst())it.getLong(0)else null}?:throw IllegalArgumentException("Séance introuvable.") + val old=db.rawQuery("SELECT session_row_id,observed_at FROM session_followups WHERE followup_id=?",arrayOf(id)).use{c->if(c.moveToFirst())c.getLong(0) to c.getString(1)else null};val revisions=item.getJSONArray("revisions");val seed=revisions.optJSONObject(0)?.optString("raw_text")?:throw IllegalArgumentException("Révision absente.") + if(old==null){db.insertOrThrow("session_followups",null,ContentValues().apply{put("followup_id",id);put("session_row_id",row);put("observed_at",at);put("raw_text",seed)});fa++} + else if(old==row to at)fs++ else throw SQLiteConstraintException("HARD CONFLICT $id") + mergeRevisions(item,id,"session_followup_revisions","followup_id","session_followups","followup_id") + } + db.setTransactionSuccessful();return TrainingFeedbackImportResult.Applied(ea,es,fa,fs) + } catch(error:SQLiteConstraintException){return TrainingFeedbackImportResult.Conflict(error.message?.substringAfterLast(' ')?:"revision")} + catch(error:IllegalArgumentException){return TrainingFeedbackImportResult.Invalid(error.message?:"Compagnon invalide.")} + catch(_:Exception){return TrainingFeedbackImportResult.DatabaseError} + finally{if(db.inTransaction())db.endTransaction()} + } + + private fun stableFeedbackId(value:String,prefix:String):Boolean = value.startsWith(prefix) && + runCatching { UUID.fromString(value.removePrefix(prefix)) }.isSuccess + private fun canonicalExerciseId(db:SQLiteDatabase,id:String):String? = db.rawQuery( + "SELECT exercise_id FROM exercises WHERE exercise_id=? UNION ALL SELECT canonical_exercise_id FROM exercise_aliases WHERE source_exercise_id=? LIMIT 1;",arrayOf(id,id)) + .use{if(it.moveToFirst())it.getString(0)else null} + /** * Build one transient suggestion from current runtime identities and the * complete actual history. No draft/history row is written by this path. @@ -5127,6 +5462,28 @@ class TrainlogRepository( ) } + /* CONTRACT: draft persistence currently rebuilds occurrence rows. + * Preserve immutable feedback by stable entry_id across that rebuild; + * feedback for an explicitly removed occurrence remains parent-owned + * and is deliberately not restored. */ + val retainedFeedback = mutableMapOf>>() + val retainedDraftRevisions = mutableMapOf>>() + db.rawQuery( + "SELECT de.entry_id,f.feedback_id,f.observed_at,f.raw_text FROM draft_exercise_feedback f " + + "JOIN draft_session_exercises de ON de.id=f.draft_session_exercise_row_id " + + "WHERE de.draft_id=?;", arrayOf(ACTIVE_DRAFT_ID.toString()), + ).use { cursor -> while (cursor.moveToNext()) retainedFeedback + .getOrPut(cursor.getString(0)) { mutableListOf() } + .add(Triple(cursor.getString(1), cursor.getString(2), cursor.getString(3))) } + db.rawQuery( + "SELECT r.feedback_id,r.revision_id,r.created_at,r.raw_text FROM draft_exercise_feedback_revisions r " + + "JOIN draft_exercise_feedback f ON f.feedback_id=r.feedback_id " + + "JOIN draft_session_exercises de ON de.id=f.draft_session_exercise_row_id WHERE de.draft_id=?;", + arrayOf(ACTIVE_DRAFT_ID.toString()), + ).use { cursor -> while (cursor.moveToNext()) retainedDraftRevisions + .getOrPut(cursor.getString(0)) { mutableListOf() } + .add(Triple(cursor.getString(1), cursor.getString(2), cursor.getString(3))) } + db.delete( "draft_session_exercises", "draft_id = ?", @@ -5178,6 +5535,21 @@ class TrainlogRepository( exerciseValues, ) + retainedFeedback[exerciseDraft.entryId].orEmpty().forEach { feedback -> + db.insertOrThrow("draft_exercise_feedback", null, ContentValues().apply { + put("feedback_id", feedback.first) + put("draft_session_exercise_row_id", draftExerciseRowId) + put("observed_at", feedback.second) + put("raw_text", feedback.third) + }) + retainedDraftRevisions[feedback.first].orEmpty().forEach { revision -> + db.insertOrThrow("draft_exercise_feedback_revisions", null, ContentValues().apply { + put("revision_id", revision.first); put("feedback_id", feedback.first) + put("created_at", revision.second); put("raw_text", revision.third) + }) + } + } + if (exerciseDraft.maxWeightKg != null) { db.insertOrThrow( "draft_max_results", @@ -5254,6 +5626,8 @@ class TrainlogRepository( ): String { val sessionId: String val sessionRowId: Long + val retainedCompletedFeedback = mutableMapOf>>() + val retainedCompletedRevisions = mutableMapOf>>() if (sourceSessionId == null) { sessionId = "se_" + UUID.randomUUID().toString() /* Preserve the existing Android meaning: started_at is assigned @@ -5261,6 +5635,9 @@ class TrainlogRepository( val sessionValues = ContentValues().apply { put("session_id", sessionId) put("started_at", OffsetDateTime.now().toString()) + /* CONTRACT: this exact explicit finalization instant anchors + * H+; old NULL values remain unknown and are never backfilled. */ + put("ended_at", OffsetDateTime.now().toString()) put("session_type", draft.sessionType.wireValue) } sessionRowId = db.insertOrThrow("sessions", null, sessionValues) @@ -5279,6 +5656,27 @@ class TrainlogRepository( check(resumedDraftIdentityIsSafe(db, sessionRowId, draft)) { "Une séance reprise ne peut supprimer, réordonner ou réaffecter ses entrées existantes." } + /* Android has one bounded completed-session correction path: + * resumed MAX_TEST finalization. Preserve feedback by the stable + * occurrence entry_id before cascading reconstruction; the + * surrounding finalization transaction owns rollback. */ + db.rawQuery( + "SELECT se.entry_id,f.feedback_id,f.observed_at,f.raw_text FROM exercise_feedback f " + + "JOIN session_exercises se ON se.id=f.session_exercise_row_id " + + "WHERE se.session_row_id=?;", arrayOf(sessionRowId.toString()), + ).use { cursor -> while (cursor.moveToNext()) retainedCompletedFeedback + .getOrPut(cursor.getString(0)) { mutableListOf() } + .add(Triple(cursor.getString(1), cursor.getString(2), cursor.getString(3))) } + db.rawQuery( + "SELECT r.feedback_id,r.revision_id,r.created_at,r.raw_text FROM exercise_feedback_revisions r " + + "JOIN exercise_feedback f ON f.feedback_id=r.feedback_id " + + "JOIN session_exercises se ON se.id=f.session_exercise_row_id WHERE se.session_row_id=?;", + arrayOf(sessionRowId.toString()), + ).use { cursor -> while (cursor.moveToNext()) retainedCompletedRevisions + .getOrPut(cursor.getString(0)) { mutableListOf() } + .add(Triple(cursor.getString(1), cursor.getString(2), cursor.getString(3))) } + db.execSQL("UPDATE sessions SET ended_at=? WHERE id=?;", + arrayOf(OffsetDateTime.now().toString(), sessionRowId)) /* INVARIANT: child replacement and draft deletion are in the * caller's transaction; failure restores the completed baseline. */ db.delete( @@ -5328,6 +5726,44 @@ class TrainlogRepository( exerciseValues, ) + retainedCompletedFeedback[exerciseDraft.entryId].orEmpty().forEach { feedback -> + db.insertOrThrow("exercise_feedback", null, ContentValues().apply { + put("feedback_id", feedback.first) + put("session_exercise_row_id", sessionExerciseRowId) + put("observed_at", feedback.second) + put("raw_text", feedback.third) + }) + retainedCompletedRevisions[feedback.first].orEmpty().forEach { revision -> + db.insertOrThrow("exercise_feedback_revisions", null, ContentValues().apply { + put("revision_id", revision.first); put("feedback_id", feedback.first) + put("created_at", revision.second); put("raw_text", revision.third) + }) + } + } + + /* INVARIANT: transfer occurs inside the caller's finalization + * transaction and matches the durable occurrence only by entry_id. + * Stable feedback identity, timestamp and raw wording are copied + * exactly; deleting the draft later removes only the draft parent. */ + db.rawQuery( + "SELECT f.feedback_id,f.observed_at,f.raw_text FROM draft_exercise_feedback f " + + "JOIN draft_session_exercises de ON de.id=f.draft_session_exercise_row_id " + + "WHERE de.draft_id=? AND de.entry_id=? ORDER BY f.observed_at,f.feedback_id;", + arrayOf(ACTIVE_DRAFT_ID.toString(), exerciseDraft.entryId), + ).use { cursor -> while (cursor.moveToNext()) { + db.insertOrThrow("exercise_feedback", null, ContentValues().apply { + put("feedback_id", cursor.getString(0)) + put("session_exercise_row_id", sessionExerciseRowId) + put("observed_at", cursor.getString(1)) + put("raw_text", cursor.getString(2)) + }) + db.execSQL( + "INSERT INTO exercise_feedback_revisions(revision_id,feedback_id,created_at,raw_text) " + + "SELECT revision_id,feedback_id,created_at,raw_text FROM draft_exercise_feedback_revisions WHERE feedback_id=?;", + arrayOf(cursor.getString(0)), + ) + } } + if (exerciseDraft.maxWeightKg != null) { db.insertOrThrow( "max_results", @@ -5798,6 +6234,9 @@ private const val MAX_PLAN_DURATION_SECONDS = 86400 private const val MAX_PLAN_REST_SECONDS = 86400 private const val MAX_EXERCISE_ALIAS_BYTES = 1024 * 1024 private const val MAX_EXERCISE_ALIASES = 4096 +/** UTF-8 byte bound, not UTF-16 code units; oversized dictation is rejected. */ +const val MAX_FEEDBACK_UTF8_BYTES = 8192 +private const val MAX_FEEDBACK_RECORDS = 4096 private const val ANDROID_LEG_PRESS_LEGACY_ID = "ex_d68a1af1-7247-4fb3-a48b-da8516906a29" private const val DESKTOP_LEG_PRESS_CANONICAL_ID = @@ -5813,7 +6252,7 @@ private class TrainlogDatabaseHelper( appContext, databaseName, null, - 13, + 15, ) { override fun onConfigure( db: SQLiteDatabase, @@ -5842,6 +6281,8 @@ private class TrainlogDatabaseHelper( createEquipmentTables(db) createBodyZoneTables(db) createExerciseAliasTable(db) + createTrainingFeedbackTables(db) + createTrainingFeedbackRevisionTables(db) seedEquipment(db) } @@ -5946,6 +6387,20 @@ private class TrainlogDatabaseHelper( version = 13 } + if (version < 14 && newVersion >= 14) { + /* CONTRACT: feedback migration is additive and deliberately does + * not manufacture ended_at for any historical session. */ + if (!tableHasColumn(db, "sessions", "ended_at")) + db.execSQL("ALTER TABLE sessions ADD COLUMN ended_at TEXT;") + createTrainingFeedbackTables(db) + version = 14 + } + + if (version < 15 && newVersion >= 15) { + migrateTrainingFeedbackToVersionFifteen(db) + version = 15 + } + if (version != newVersion) { error( "Unsupported Android DB upgrade " + @@ -5985,6 +6440,65 @@ private class TrainlogDatabaseHelper( db.execSQL("CREATE INDEX IF NOT EXISTS exercise_aliases_canonical ON exercise_aliases(canonical_exercise_id);") } + private fun createTrainingFeedbackTables(db: SQLiteDatabase) { + /* WHY: immutable subjective wording is distinct from actual training + * facts. INVARIANT: only Android APIs create rows; peer imports merely + * merge identical stable IDs and never update or delete them. */ + db.execSQL( + "CREATE TABLE IF NOT EXISTS exercise_feedback(" + + "id INTEGER PRIMARY KEY,feedback_id TEXT NOT NULL UNIQUE," + + "session_exercise_row_id INTEGER NOT NULL REFERENCES session_exercises(id) ON DELETE CASCADE," + + "observed_at TEXT NOT NULL,raw_text TEXT NOT NULL CHECK(length(trim(raw_text))>0));", + ) + db.execSQL("CREATE INDEX IF NOT EXISTS exercise_feedback_timeline ON exercise_feedback(session_exercise_row_id,observed_at,feedback_id);") + db.execSQL( + "CREATE TABLE IF NOT EXISTS draft_exercise_feedback(" + + "id INTEGER PRIMARY KEY,feedback_id TEXT NOT NULL UNIQUE," + + "draft_session_exercise_row_id INTEGER NOT NULL REFERENCES draft_session_exercises(id) ON DELETE CASCADE," + + "observed_at TEXT NOT NULL,raw_text TEXT NOT NULL CHECK(length(trim(raw_text))>0));", + ) + db.execSQL("CREATE INDEX IF NOT EXISTS draft_exercise_feedback_timeline ON draft_exercise_feedback(draft_session_exercise_row_id,observed_at,feedback_id);") + db.execSQL( + "CREATE TABLE IF NOT EXISTS session_followups(" + + "id INTEGER PRIMARY KEY,followup_id TEXT NOT NULL UNIQUE," + + "session_row_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE," + + "observed_at TEXT NOT NULL,raw_text TEXT NOT NULL CHECK(length(trim(raw_text))>0));", + ) + db.execSQL("CREATE INDEX IF NOT EXISTS session_followups_timeline ON session_followups(session_row_id,observed_at,followup_id);") + } + + private fun createTrainingFeedbackRevisionTables(db: SQLiteDatabase) { + /* WHY: a wording correction is history about one observation, not a + * replacement observation. CONTRACT: roots retain raw_text only as a + * transactionally maintained current-text cache. INVARIANT: revision + * rows are append-only and are ordered by parsed instant then stable ID. */ + db.execSQL("CREATE TABLE IF NOT EXISTS exercise_feedback_revisions(" + + "revision_id TEXT PRIMARY KEY,feedback_id TEXT NOT NULL REFERENCES exercise_feedback(feedback_id) ON DELETE CASCADE," + + "created_at TEXT NOT NULL,raw_text TEXT NOT NULL CHECK(length(trim(raw_text))>0));") + db.execSQL("CREATE INDEX IF NOT EXISTS exercise_feedback_revisions_current ON exercise_feedback_revisions(feedback_id,created_at,revision_id);") + db.execSQL("CREATE TABLE IF NOT EXISTS draft_exercise_feedback_revisions(" + + "revision_id TEXT PRIMARY KEY,feedback_id TEXT NOT NULL REFERENCES draft_exercise_feedback(feedback_id) ON DELETE CASCADE," + + "created_at TEXT NOT NULL,raw_text TEXT NOT NULL CHECK(length(trim(raw_text))>0));") + db.execSQL("CREATE INDEX IF NOT EXISTS draft_exercise_feedback_revisions_current ON draft_exercise_feedback_revisions(feedback_id,created_at,revision_id);") + db.execSQL("CREATE TABLE IF NOT EXISTS session_followup_revisions(" + + "revision_id TEXT PRIMARY KEY,followup_id TEXT NOT NULL REFERENCES session_followups(followup_id) ON DELETE CASCADE," + + "created_at TEXT NOT NULL,raw_text TEXT NOT NULL CHECK(length(trim(raw_text))>0));") + db.execSQL("CREATE INDEX IF NOT EXISTS session_followup_revisions_current ON session_followup_revisions(followup_id,created_at,revision_id);") + } + + private fun migrateTrainingFeedbackToVersionFifteen(db: SQLiteDatabase) { + createTrainingFeedbackRevisionTables(db) + /* CONTRACT: fr0_ is the cross-peer deterministic revision-one + * identity. INSERT OR IGNORE makes v14 migration and later V1 import + * converge without manufacturing duplicate initial revisions. */ + db.execSQL("INSERT OR IGNORE INTO exercise_feedback_revisions(revision_id,feedback_id,created_at,raw_text) " + + "SELECT 'fr0_'||feedback_id,feedback_id,observed_at,raw_text FROM exercise_feedback;") + db.execSQL("INSERT OR IGNORE INTO draft_exercise_feedback_revisions(revision_id,feedback_id,created_at,raw_text) " + + "SELECT 'fr0_'||feedback_id,feedback_id,observed_at,raw_text FROM draft_exercise_feedback;") + db.execSQL("INSERT OR IGNORE INTO session_followup_revisions(revision_id,followup_id,created_at,raw_text) " + + "SELECT 'fr0_'||followup_id,followup_id,observed_at,raw_text FROM session_followups;") + } + private fun seedInitialBodyZones(db: SQLiteDatabase) { val catalog = BodyZoneCatalog.load(appContext) catalog.initialMappings.forEach { mapping -> @@ -6381,6 +6895,7 @@ private class TrainlogDatabaseHelper( id INTEGER PRIMARY KEY, session_id TEXT NOT NULL UNIQUE, started_at TEXT NOT NULL, + ended_at TEXT, session_type TEXT NOT NULL CHECK( session_type IN ( diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogTimestamp.kt b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogTimestamp.kt index 1d7674e..fae86a8 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogTimestamp.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogTimestamp.kt @@ -121,3 +121,21 @@ internal object TrainlogTimestamp { return days + day - 1 } } + +/** Shared Android presentation rule; exact instants are parsed before + * subtraction and a missing/negative anchor never produces a fabricated H+. */ +fun sessionFollowUpElapsedLabel(endedAt: String?, observedAt: String): String { + val end = endedAt?.let(TrainlogTimestamp::parse) ?: return "H+?" + val observed = TrainlogTimestamp.parse(observedAt) ?: return "H+?" + val seconds = observed.utcSecond - end.utcSecond + return if (seconds < 0) "H+?" else "H+${seconds / 3600L}" +} + +/** Exercise observations may precede finalization and therefore use a + * session-neutral label instead of a negative or misleading H+ value. */ +fun exerciseFeedbackElapsedLabel(endedAt: String?, observedAt: String): String { + val end = endedAt?.let(TrainlogTimestamp::parse) ?: return "Ressenti" + val observed = TrainlogTimestamp.parse(observedAt) ?: return "Ressenti" + val seconds = observed.utcSecond - end.utcSecond + return if (seconds < 0) "Pendant la séance" else "H+${seconds / 3600L}" +} diff --git a/android/app/src/main/java/com/labfytools/trainlog/model/SessionModels.kt b/android/app/src/main/java/com/labfytools/trainlog/model/SessionModels.kt index e050b2d..2d68d3e 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/model/SessionModels.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/model/SessionModels.kt @@ -109,6 +109,44 @@ data class SessionSummary( val startedAt: String, val exerciseCount: Int, val sessionType: SessionType = SessionType.TRAINING, + /** Null is preserved for historical sessions without a trustworthy anchor. */ + val endedAt: String? = null, +) + +data class ExerciseFeedback( + val feedbackId: String, + val sessionId: String, + val entryId: String, + val exerciseId: String, + val observedAt: String, + val rawText: String, + val modified: Boolean = false, +) + +/** Android-local feedback owned by an unfinished occurrence. */ +data class DraftExerciseFeedback( + val feedbackId: String, + val entryId: String, + val exerciseId: String, + val observedAt: String, + val rawText: String, + val modified: Boolean = false, +) + +data class SessionFollowUp( + val followupId: String, + val sessionId: String, + val observedAt: String, + val rawText: String, + val modified: Boolean = false, +) + +/** Immutable wording revision. The parent observation timestamp is deliberately + * absent: correcting wording never creates a new physiological observation. */ +data class FeedbackRevision( + val revisionId: String, + val createdAt: String, + val rawText: String, ) data class SessionExerciseDetail( @@ -128,11 +166,13 @@ data class SessionExerciseDetail( val continuousDurationSeconds: Int = 0, val speedKmh: Double? = null, val distanceKm: Double? = null, + val feedback: List = emptyList(), ) data class SessionDetail( val summary: SessionSummary, val exercises: List, + val followUps: List = emptyList(), ) data class LatestExerciseMax( diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/AndroidSpeechRecognitionAdapter.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/AndroidSpeechRecognitionAdapter.kt new file mode 100644 index 0000000..6a97692 --- /dev/null +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/AndroidSpeechRecognitionAdapter.kt @@ -0,0 +1,54 @@ +package com.labfytools.trainlog.ui + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.speech.RecognitionListener +import android.speech.RecognizerIntent +import android.speech.SpeechRecognizer + +class AndroidSpeechRecognitionAdapter(context: Context) : SpeechRecognitionAdapter { + private val appContext = context.applicationContext + private var recognizer: SpeechRecognizer? = null + override val available: Boolean get() = SpeechRecognizer.isRecognitionAvailable(appContext) + + override fun start(languageTag: String, listener: SpeechRecognitionAdapter.Listener) { + val instance = recognizer ?: SpeechRecognizer.createSpeechRecognizer(appContext).also { recognizer = it } + instance.setRecognitionListener(object : RecognitionListener { + override fun onPartialResults(results: Bundle) = listener.onPartial(best(results)) + override fun onResults(results: Bundle) = listener.onFinal(best(results)) + override fun onError(error: Int) = listener.onError("Reconnaissance interrompue (code $error).") + override fun onReadyForSpeech(params: Bundle?) = Unit + override fun onBeginningOfSpeech() = Unit + override fun onRmsChanged(rmsdB: Float) = Unit + override fun onBufferReceived(buffer: ByteArray?) = Unit + override fun onEndOfSpeech() = Unit + override fun onEvent(eventType: Int, params: Bundle?) = Unit + }) + val intent = buildTrainlogRecognitionIntent(languageTag, + android.os.Build.VERSION.SDK_INT >= 31 && SpeechRecognizer.isOnDeviceRecognitionAvailable(appContext)) + instance.startListening(intent) + } + override fun stop() { recognizer?.stopListening() } + override fun destroy() { recognizer?.destroy(); recognizer = null } + private fun best(bundle: Bundle): String = + bundle.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)?.firstOrNull().orEmpty() +} + +internal const val COMPLETE_SILENCE_MILLIS = 1500L +internal const val POSSIBLY_COMPLETE_SILENCE_MILLIS = 1100L + +/** Best-effort recognizer hints only: engines may ignore silence extras. + * CONTRACT: Trainlog does not synthesize results or automatically restart a + * naturally completed recognizer session. The listener owns real engine text. */ +internal fun buildTrainlogRecognitionIntent(languageTag: String, preferOffline: Boolean): Intent = + Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { + putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) + putExtra(RecognizerIntent.EXTRA_LANGUAGE, languageTag) + putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true) + putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_COMPLETE_SILENCE_LENGTH_MILLIS, + COMPLETE_SILENCE_MILLIS) + putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_POSSIBLY_COMPLETE_SILENCE_LENGTH_MILLIS, + POSSIBLY_COMPLETE_SILENCE_MILLIS) + if (preferOffline) putExtra(RecognizerIntent.EXTRA_PREFER_OFFLINE, true) + } diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/FeedbackDictation.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/FeedbackDictation.kt new file mode 100644 index 0000000..f65777d --- /dev/null +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/FeedbackDictation.kt @@ -0,0 +1,89 @@ +package com.labfytools.trainlog.ui + +/** Explicit, platform-independent dictation state used by Compose and tests. */ +enum class DictationPhase { Idle, Listening, Editing, Saving, Error } + +data class DictationState( + val phase: DictationPhase = DictationPhase.Idle, + val committedText: String = "", + val partialText: String = "", + val message: String? = null, +) { + val visibleText: String get() = appendSegment(committedText, partialText) +} + +/** + * CONTRACT: recognition events transport text only. Implementations must not + * record, cache, or persist microphone audio. destroy() releases callbacks and + * the platform recognizer; it is safe to call more than once. + */ +interface SpeechRecognitionAdapter { + val available: Boolean + fun start(languageTag: String, listener: Listener) + fun stop() + fun destroy() + interface Listener { + fun onPartial(text: String) + fun onFinal(text: String) + fun onError(message: String) + } +} + +class FeedbackDictationController( + private val recognizer: SpeechRecognitionAdapter, + private val onChanged: (DictationState) -> Unit = {}, +) : SpeechRecognitionAdapter.Listener { + var state = DictationState() + private set + + private fun update(value: DictationState) { state = value; onChanged(value) } + fun edit(text: String) { if (state.phase != DictationPhase.Listening) update(state.copy( + phase = DictationPhase.Editing, committedText = text, partialText = "", message = null)) } + + fun start(permissionGranted: Boolean) { + if (!permissionGranted) { + update(state.copy(phase = DictationPhase.Error, partialText = "", + message = "Microphone refusé : la saisie manuelle reste disponible.")) + } else if (!recognizer.available) { + update(state.copy(phase = DictationPhase.Error, partialText = "", + message = "Reconnaissance vocale indisponible : utilisez la saisie manuelle.")) + } else { + update(state.copy(phase = DictationPhase.Listening, partialText = "", message = null)) + recognizer.start("fr-FR", this) + } + } + + fun stop() { + if (state.phase == DictationPhase.Listening) { + recognizer.stop() + update(state.copy(phase = DictationPhase.Editing, committedText = state.visibleText, + partialText = "")) + } + } + + override fun onPartial(text: String) { if (state.phase == DictationPhase.Listening) + update(state.copy(partialText = text)) } + override fun onFinal(text: String) { if (state.phase == DictationPhase.Listening) + update(state.copy(phase = DictationPhase.Editing, + committedText = appendSegment(state.committedText, text), partialText = "")) } + override fun onError(message: String) { + update(state.copy(phase = DictationPhase.Error, committedText = state.visibleText, + partialText = "", message = message)) + } + fun beginSaving(): String? { + val text = state.visibleText.trim() + if (text.isBlank()) { update(state.copy(phase = DictationPhase.Error, + message = "Un ressenti vide ne peut pas être enregistré.")); return null } + update(state.copy(phase = DictationPhase.Saving, committedText = text, partialText = "")) + return text + } + fun saveFailed(message: String) { update(state.copy(phase = DictationPhase.Error, message = message)) } + fun cancel() { recognizer.stop(); update(DictationState()) } + fun destroy() { recognizer.destroy() } +} + +internal fun appendSegment(base: String, segment: String): String = when { + base.isBlank() -> segment + segment.isBlank() -> base + else -> base.trimEnd() + " " + segment.trimStart() +} diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/FeedbackEditor.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/FeedbackEditor.kt new file mode 100644 index 0000000..477c468 --- /dev/null +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/FeedbackEditor.kt @@ -0,0 +1,92 @@ +package com.labfytools.trainlog.ui + +import android.Manifest +import android.content.pm.PackageManager +import android.content.Context +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.verticalScroll +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import com.labfytools.trainlog.ui.theme.TrainlogTypography +import androidx.core.content.ContextCompat +import com.labfytools.trainlog.data.MAX_FEEDBACK_UTF8_BYTES +import com.labfytools.trainlog.ui.theme.LocalTrainlogColors + +@Composable +fun FeedbackEditor( + onSave: (String) -> String?, + onCancel: () -> Unit, + initialText: String = "", + editing: Boolean = false, + recognizerFactory: ((Context) -> SpeechRecognitionAdapter)? = null, +) { + val context = LocalContext.current + val colors = LocalTrainlogColors.current + var state by remember(initialText) { mutableStateOf(DictationState(committedText = initialText)) } + val controller = remember(initialText) { FeedbackDictationController( + recognizerFactory?.invoke(context) ?: AndroidSpeechRecognitionAdapter(context), + onChanged = { state = it }).also { + if (initialText.isNotEmpty()) it.edit(initialText) + } } + val permission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + controller.start(granted) + } + DisposableEffect(controller) { onDispose { controller.destroy() } } + val transcriptScroll = rememberScrollState() + /* Scroll only when layout overflow grows. Partial tokens that still fit do + * not move the viewport, so manual reading is not overridden continuously. */ + LaunchedEffect(transcriptScroll.maxValue, state.phase) { + if (state.phase == DictationPhase.Listening && + transcriptScroll.maxValue > transcriptScroll.value) { + transcriptScroll.scrollTo(transcriptScroll.maxValue) + } + } + + TrainlogFrame(title = "Dictée du ressenti") { + if (state.phase == DictationPhase.Listening) { + TrainlogInfo("● Enregistrement en cours", color = colors.error) + TrainlogIconAction(TrainlogIcons.Stop, "Arrêter la dictée", + onClick = { controller.stop() }, accent = colors.warning, + modifier = Modifier.testTag("feedback-stop")) + Box(Modifier.fillMaxWidth().heightIn(min = 96.dp, max = 192.dp) + .background(colors.surface).verticalScroll(transcriptScroll) + .padding(12.dp).testTag("feedback-live-transcript")) { + BasicText(state.visibleText, style = TrainlogTypography.normal.copy(color = colors.text)) + } + } else { + TrainlogInputField("Texte", state.visibleText, onValueChange = { + if (it.toByteArray(Charsets.UTF_8).size <= MAX_FEEDBACK_UTF8_BYTES) controller.edit(it) + }, testTag = "feedback-edit-transcript", singleLine = false, minLines = 4, maxLines = 8) + val resume = state.committedText.isNotBlank() + TrainlogIconAction(TrainlogIcons.Mic, + if (resume) "Reprendre la dictée" else "Démarrer la dictée", onClick = { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) + controller.start(true) else permission.launch(Manifest.permission.RECORD_AUDIO) + }, accent = colors.accent, modifier = Modifier.testTag("feedback-mic")) + TrainlogAction(if (editing) "Enregistrer les modifications" else "Enregistrer", + if (editing) "Créer une nouvelle révision immutable." else "Créer un enregistrement historique immutable.", onClick = { + controller.beginSaving()?.let { text -> onSave(text)?.let(controller::saveFailed) } + }, accent = colors.success) + TrainlogAction("Annuler", "Ne rien enregistrer.", + onClick = { controller.cancel(); onCancel() }, accent = colors.muted) + } + state.message?.let { TrainlogInfo(it, color = colors.error) } + } +} diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/SessionDetailScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/SessionDetailScreen.kt index a427ce1..9b78508 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/SessionDetailScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/SessionDetailScreen.kt @@ -5,7 +5,12 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import com.labfytools.trainlog.data.TrainlogRepository +import com.labfytools.trainlog.data.SaveFeedbackResult +import com.labfytools.trainlog.data.exerciseFeedbackElapsedLabel +import com.labfytools.trainlog.data.sessionFollowUpElapsedLabel import com.labfytools.trainlog.data.ActiveDraftMutationResult import com.labfytools.trainlog.data.EquipmentLoadSemantics import com.labfytools.trainlog.model.ExerciseDataFields @@ -27,8 +32,13 @@ fun SessionDetailScreen( var revision by remember(sessionId) { mutableStateOf(0) } var editingEntryId by remember(sessionId) { mutableStateOf(null) } + var pendingEquipmentChange by remember(sessionId) { mutableStateOf?>(null) } var equipmentQuery by remember(sessionId) { mutableStateOf("") } var resumeMessage by remember(sessionId) { mutableStateOf(null) } + var feedbackEntryId by remember(sessionId) { mutableStateOf(null) } + var editingFeedbackId by remember(sessionId) { mutableStateOf(null) } + var editingFollowUpId by remember(sessionId) { mutableStateOf(null) } + var addingFollowUp by remember(sessionId) { mutableStateOf(false) } val detail = remember(sessionId, revision) { @@ -136,14 +146,13 @@ fun SessionDetailScreen( value = equipmentQuery, onValueChange = { equipmentQuery = it }, ) - TrainlogAction( - label = "Retirer l'équipement", - description = "Conserver l'exercice sans machine associée.", + TrainlogIconAction( + icon = TrainlogIcons.DeleteOutline, + contentDescription = "Retirer la machine de cet exercice", accent = colors.warning, + modifier = Modifier.testTag("delete-completed-equipment-${exercise.entryId}"), onClick = { - if (repository.setCompletedSessionEquipment(detail.summary.sessionId, exercise.entryId, null)) { - revision++; editingEntryId = null; equipmentQuery = "" - } + pendingEquipmentChange = exercise.entryId to null }, ) repository.searchEquipment(equipmentQuery).take(8).forEach { equipment -> @@ -152,9 +161,11 @@ fun SessionDetailScreen( description = equipment.labelName.ifBlank { equipment.type }, accent = colors.success, onClick = { - if (repository.setCompletedSessionEquipment(detail.summary.sessionId, exercise.entryId, equipment.equipmentId)) { - revision++; editingEntryId = null; equipmentQuery = "" - } + if (exercise.equipmentId == null) { + if (repository.setCompletedSessionEquipment(detail.summary.sessionId, exercise.entryId, equipment.equipmentId)) { + revision++; editingEntryId = null; equipmentQuery = "" + } + } else pendingEquipmentChange = exercise.entryId to equipment.equipmentId }, ) } @@ -188,8 +199,76 @@ fun SessionDetailScreen( ?.loadSemantics, ) } + exercise.feedback.forEach { item -> + TrainlogInfo("${exerciseFeedbackElapsedLabel(detail.summary.endedAt, item.observedAt)} · ${item.rawText}${if (item.modified) " · Modifié" else ""}") + TrainlogAction("Modifier", "Corriger le texte sans déplacer l’observation.", + onClick = { editingFeedbackId = item.feedbackId }, accent = colors.muted) + if (editingFeedbackId == item.feedbackId) FeedbackEditor( + initialText = item.rawText, editing = true, + onSave = { text -> when (val result = repository.reviseExerciseFeedback(item.feedbackId, text)) { + is SaveFeedbackResult.Saved -> { editingFeedbackId = null; revision++; null } + is SaveFeedbackResult.Invalid -> result.message + is SaveFeedbackResult.DatabaseError -> result.message + } }, onCancel = { editingFeedbackId = null }) + } + if (feedbackEntryId == exercise.entryId) { + FeedbackEditor(onSave = { text -> + when (val result = repository.saveExerciseFeedback(detail.summary.sessionId, exercise.entryId, text)) { + is SaveFeedbackResult.Saved -> { feedbackEntryId = null; revision++; null } + is SaveFeedbackResult.Invalid -> result.message + is SaveFeedbackResult.DatabaseError -> result.message + } + }, onCancel = { feedbackEntryId = null }) + } else { + TrainlogAction( + label = if (exercise.feedback.isEmpty()) "🎙 Ressenti" else "+ Ajouter un ressenti", + description = "Ajouter une observation subjective à cette occurrence.", + accent = colors.accent, + onClick = { feedbackEntryId = exercise.entryId }, + ) + } } } + + TrainlogFrame(title = "Suivi après séance") { + detail.followUps.forEach { item -> + TrainlogInfo(sessionFollowUpElapsedLabel(detail.summary.endedAt, item.observedAt), color = colors.accent) + TrainlogInfo(item.rawText + if (item.modified) " · Modifié" else "") + TrainlogAction("Modifier", "Corriger le texte sans changer le repère H+.", + onClick = { editingFollowUpId = item.followupId }, accent = colors.muted) + if (editingFollowUpId == item.followupId) FeedbackEditor( + initialText = item.rawText, editing = true, + onSave = { text -> when (val result = repository.reviseSessionFollowUp(item.followupId, text)) { + is SaveFeedbackResult.Saved -> { editingFollowUpId = null; revision++; null } + is SaveFeedbackResult.Invalid -> result.message + is SaveFeedbackResult.DatabaseError -> result.message + } }, onCancel = { editingFollowUpId = null }) + } + if (addingFollowUp) { + FeedbackEditor(onSave = { text -> + when (val result = repository.saveSessionFollowUp(detail.summary.sessionId, text)) { + is SaveFeedbackResult.Saved -> { addingFollowUp = false; revision++; null } + is SaveFeedbackResult.Invalid -> result.message + is SaveFeedbackResult.DatabaseError -> result.message + } + }, onCancel = { addingFollowUp = false }) + } else TrainlogAction("+ Ajouter un enregistrement", + "Ajouter une observation chronologique sans l’attribuer à un exercice.", + onClick = { addingFollowUp = true }, accent = colors.success) + } + pendingEquipmentChange?.let { (entryId, equipmentId) -> + DestructiveConfirmationDialog( + title = if (equipmentId == null) "Retirer la machine de cette entrée ?" else "Remplacer la machine associée ?", + detail = "L’association de machine actuellement enregistrée sera perdue. Les séries, MAX et ressentis restent inchangés.", + confirmLabel = if (equipmentId == null) "Retirer" else "Remplacer", + onCancel = { pendingEquipmentChange = null }, + onConfirm = { + if (repository.setCompletedSessionEquipment(detail.summary.sessionId,entryId,equipmentId)) { + revision++;editingEntryId=null;equipmentQuery="";pendingEquipmentChange=null + } + }, + ) + } } } diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt index 89d4d6f..9a4a486 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt @@ -31,6 +31,7 @@ import com.labfytools.trainlog.data.CreateEquipmentResult import com.labfytools.trainlog.data.EquipmentLoadSemantics import com.labfytools.trainlog.data.FinalizeActiveDraftResult import com.labfytools.trainlog.data.ManualPercentMaxResult +import com.labfytools.trainlog.data.SaveFeedbackResult import com.labfytools.trainlog.data.TrainlogRepository import com.labfytools.trainlog.model.ActiveSessionDraft import com.labfytools.trainlog.model.ExerciseDataFields @@ -103,6 +104,12 @@ fun SessionScreen( mutableStateOf(false) } + var feedbackEntryId by remember { mutableStateOf(null) } + var editingFeedbackId by remember { mutableStateOf(null) } + var pendingRemoval by remember { mutableStateOf?>(null) } + var pendingDestructiveEdit by remember { mutableStateOf?>(null) } + var feedbackRevision by remember { mutableStateOf(0) } + val persistDraft: (ActiveSessionDraft, String?) -> Unit = { updated, successMessage -> @@ -316,30 +323,68 @@ fun SessionScreen( ), null, ) + message = "Éditeur ouvert pour ${draft.exercise.name} — les champs préremplis apparaissent ci-dessous." }, ) + if (currentDraft.form.editingEntryId == draft.entryId) { + TrainlogInfo("Modification en cours — faites défiler jusqu’à SAISIE — ${draft.exercise.name}.", color = colors.accent) + } + + val draftFeedback = remember(feedbackRevision, draft.entryId) { + repository.listDraftExerciseFeedback(draft.entryId) + } + draftFeedback.forEach { feedback -> + TrainlogInfo( + text = "Ressenti · ${feedback.rawText}${if (feedback.modified) " · Modifié" else ""}", + color = colors.muted, + ) + TrainlogAction("Modifier", "Corriger le texte sans changer l’observation.", + onClick = { editingFeedbackId = feedback.feedbackId }, accent = colors.accent) + if (editingFeedbackId == feedback.feedbackId) FeedbackEditor( + initialText = feedback.rawText, editing = true, + onSave = { text -> when (val result = repository.reviseDraftExerciseFeedback(feedback.feedbackId, text)) { + is SaveFeedbackResult.Saved -> { feedbackRevision++; editingFeedbackId = null; null } + is SaveFeedbackResult.Invalid -> result.message + is SaveFeedbackResult.DatabaseError -> result.message + } }, onCancel = { editingFeedbackId = null }) + } + TrainlogAction( + label = if (draftFeedback.isEmpty()) { + "🎙 Ressenti" + } else { + "Ressenti (${draftFeedback.size}) · + Ajouter un ressenti" + }, + description = "Dicter ou saisir un ressenti maintenant, avant la fin de la séance.", + accent = colors.success, + modifier = Modifier.testTag("draft-feedback-${draft.entryId}"), + onClick = { feedbackEntryId = draft.entryId }, + ) + if (feedbackEntryId == draft.entryId) { + FeedbackEditor( + onSave = { text -> + when (val result = repository.saveDraftExerciseFeedback(draft.entryId, text)) { + is SaveFeedbackResult.Saved -> { + feedbackRevision += 1 + feedbackEntryId = null + message = "Ressenti sauvegardé dans la séance en cours." + null + } + is SaveFeedbackResult.Invalid -> result.message + is SaveFeedbackResult.DatabaseError -> result.message + } + }, + onCancel = { feedbackEntryId = null }, + ) + } if (currentDraft.sourceSessionId == null) { - TrainlogAction( - label = - "Retirer ${draft.exercise.name}", - description = - "Supprimer cet exercice de la séance en cours.", - accent = - colors.error, + TrainlogIconAction( + icon = TrainlogIcons.DeleteOutline, + contentDescription = "Supprimer cet exercice", + accent = colors.error, + modifier = Modifier.testTag("delete-draft-exercise-${draft.entryId}"), onClick = { - persistDraft( - currentDraft.copy( - exercises = - currentDraft.exercises - .filterIndexed { - itemIndex, - _ -> - itemIndex != index - } - ), - "Exercice retiré de la séance.", - ) + pendingRemoval = index to draft }, ) } @@ -388,7 +433,14 @@ fun SessionScreen( onAdd = { draft -> val editIndex = currentDraft.form.editingExerciseIndex - persistDraft( + val previous = editIndex?.let(currentDraft.exercises::getOrNull) + if (editIndex != null && previous != null && + (previous.exercise.recordingMode != draft.exercise.recordingMode || + previous.exercise.trackingMode != draft.exercise.trackingMode) && + (previous.sets.isNotEmpty() || previous.continuousDurationSeconds > 0 || + previous.maxWeightKg != null || previous.plan != null)) { + pendingDestructiveEdit = editIndex to draft + } else persistDraft( currentDraft.copy( exercises = editIndex?.let { replacingIndex -> currentDraft.exercises.mapIndexed { index, existing -> @@ -456,28 +508,23 @@ fun SessionScreen( }, ) - TrainlogAction( - label = "Supprimer la séance en cours", - description = - "Supprimer uniquement ce brouillon local.", + TrainlogIconAction( + icon = TrainlogIcons.DeleteOutline, + contentDescription = "Supprimer la séance en cours", accent = colors.error, + modifier = Modifier.testTag("delete-active-draft"), onClick = { confirmingDiscard = true }, ) if (confirmingDiscard) { - TrainlogInfo( - text = - "Cette suppression n'ajoutera rien à l'historique.", - color = colors.error, - ) - TrainlogAction( - label = "Confirmer la suppression", - description = - "Supprimer définitivement la séance en cours.", - accent = colors.error, - onClick = { + DestructiveConfirmationDialog( + title = "Abandonner la séance en cours ?", + detail = "Cette action supprimera le brouillon, ses ${currentDraft.exercises.size} exercice(s) et toutes leurs données enregistrées.", + confirmLabel = "Abandonner", + onCancel = { confirmingDiscard = false }, + onConfirm = { when ( val result = repository @@ -494,15 +541,6 @@ fun SessionScreen( } }, ) - TrainlogAction( - label = "Annuler", - description = - "Conserver la séance en cours.", - accent = colors.muted, - onClick = { - confirmingDiscard = false - }, - ) } if ( @@ -525,6 +563,47 @@ fun SessionScreen( ) } } + + pendingRemoval?.let { (index, draft) -> + val feedbackCount = repository.listDraftExerciseFeedback(draft.entryId).size + val facts = buildList { + if (draft.sets.isNotEmpty()) add("${draft.sets.size} série(s)") + if (draft.continuousDurationSeconds > 0) add("l’activité continue") + if (draft.maxWeightKg != null) add("la donnée MAX") + if (feedbackCount > 0) add("$feedbackCount ressenti(s)") + } + DestructiveConfirmationDialog( + title = "Supprimer \"${draft.exercise.name}\" de la séance ?", + detail = if (facts.isEmpty()) "Cette occurrence sera retirée." else + "Cette action supprimera :\n- " + facts.joinToString("\n- "), + confirmLabel = "Supprimer", + onCancel = { pendingRemoval = null }, + onConfirm = { + persistDraft(currentDraft.copy(exercises = currentDraft.exercises.filterIndexed { i, _ -> i != index }), + "Exercice retiré de la séance.") + pendingRemoval = null + }, + ) + } + pendingDestructiveEdit?.let { (index, replacement) -> + val previous=currentDraft.exercises[index] + val losses=buildList { + if(previous.sets.isNotEmpty())add("${previous.sets.size} série(s)") + if(previous.continuousDurationSeconds>0)add("l’activité continue") + if(previous.maxWeightKg!=null)add("la donnée MAX") + if(previous.plan!=null)add("les objectifs et le repos") + } + DestructiveConfirmationDialog( + title="Changer le profil de ${previous.exercise.name} ?", + detail="Ce changement incompatible remplacera :\n- "+losses.joinToString("\n- ")+"\nLes ressentis restent attachés au même entry_id.", + confirmLabel="Remplacer", + onCancel={pendingDestructiveEdit=null}, + onConfirm={ + persistDraft(currentDraft.copy(exercises=currentDraft.exercises.mapIndexed{i,item->if(i==index)replacement else item},form=SessionDraftForm()),"Exercice modifié.") + pendingDestructiveEdit=null + }, + ) + } } } @@ -812,6 +891,7 @@ private fun SessionExerciseForm( initialSetRows ) } + var pendingSetRemoval by remember(key) { mutableStateOf(null) } var maxWeightText by remember(key) { @@ -1059,22 +1139,13 @@ private fun SessionExerciseForm( ) }, ) - TrainlogAction( - label = "Supprimer la série ${index + 1}", - description = "Retirer uniquement cette série.", + TrainlogIconAction( + icon = TrainlogIcons.DeleteOutline, + contentDescription = "Supprimer cette série", accent = colors.error, + modifier = Modifier.testTag("delete-set-${index + 1}"), onClick = { - val updated = setRows.filterIndexed { rowIndex, _ -> rowIndex != index } - setRows = updated - repsText = encodeRawReps(updated) - weightText = encodeRawWeights(updated) - error = null - onFormChanged( - currentForm( - exercise, setCountText, repsText, durationText, - speedText, distanceText, selectedEquipmentId, weightText, - ) - ) + pendingSetRemoval = index }, ) } @@ -1310,6 +1381,20 @@ private fun SessionExerciseForm( color = colors.error, ) } + pendingSetRemoval?.let { index -> + DestructiveConfirmationDialog( + title = "Supprimer la série ${index + 1} ?", + detail = "Ses répétitions, sa durée et sa charge saisies seront supprimées.", + confirmLabel = "Supprimer", + onCancel = { pendingSetRemoval = null }, + onConfirm = { + val updated=setRows.filterIndexed { rowIndex, _ -> rowIndex != index } + setRows=updated;repsText=encodeRawReps(updated);weightText=encodeRawWeights(updated);error=null + onFormChanged(currentForm(exercise,setCountText,repsText,durationText,speedText,distanceText,selectedEquipmentId,weightText,maxWeightText)) + pendingSetRemoval=null + }, + ) + } } } diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogComponents.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogComponents.kt index 07a4ed6..02bc82d 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogComponents.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogComponents.kt @@ -24,8 +24,12 @@ import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button +import androidx.compose.material3.AlertDialog import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -35,10 +39,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties import com.labfytools.trainlog.ui.theme.LocalTrainlogColors import com.labfytools.trainlog.ui.theme.TrainlogTypography @@ -181,6 +187,22 @@ fun TrainlogAction( } } +/** Compact pictogram action with a mandatory accessible name. */ +@Composable +fun TrainlogIconAction( + icon: ImageVector, + contentDescription: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + accent: Color? = null, +) { + val colors = LocalTrainlogColors.current + IconButton(onClick = onClick, modifier = modifier.heightIn(min = 48.dp)) { + Icon(icon, contentDescription = contentDescription, + tint = accent ?: colors.accent) + } +} + @Composable fun TrainlogPrimaryAction(label: String, description: String, onClick: () -> Unit) { val colors = LocalTrainlogColors.current @@ -228,6 +250,9 @@ fun TrainlogInputField( keyboardOptions: KeyboardOptions = KeyboardOptions.Default, testTag: String? = null, + singleLine: Boolean = true, + minLines: Int = 1, + maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, ) { val colors = LocalTrainlogColors.current @@ -265,7 +290,9 @@ fun TrainlogInputField( BasicTextField( value = value, onValueChange = onValueChange, - singleLine = true, + singleLine = singleLine, + minLines = minLines, + maxLines = maxLines, keyboardOptions = keyboardOptions, cursorBrush = SolidColor(colors.accent), @@ -340,3 +367,24 @@ fun TrainlogInfo( ), ) } + +/** Shared destructive commit gate. CONTRACT: dismissal and Back are safe, + * outside taps cannot confirm, and only the explicit labelled button invokes + * the destructive callback. */ +@Composable +fun DestructiveConfirmationDialog( + title: String, + detail: String, + confirmLabel: String, + onCancel: () -> Unit, + onConfirm: () -> Unit, +) { + AlertDialog( + onDismissRequest = onCancel, + title = { Text(title) }, + text = { Text(detail) }, + dismissButton = { TextButton(onClick = onCancel) { Text("Annuler") } }, + confirmButton = { TextButton(onClick = onConfirm) { Text(confirmLabel) } }, + properties = DialogProperties(dismissOnBackPress = true, dismissOnClickOutside = false), + ) +} diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogIcons.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogIcons.kt new file mode 100644 index 0000000..4ceec6c --- /dev/null +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogIcons.kt @@ -0,0 +1,101 @@ +package com.labfytools.trainlog.ui + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +/** The two voice glyphs are local to avoid shipping the large extended icon catalog. */ +internal object TrainlogIcons { + val Mic: ImageVector by lazy { + ImageVector.Builder( + name = "Mic", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ).apply { + path(fill = SolidColor(Color.Black)) { + moveTo(12f, 14f) + curveTo(13.66f, 14f, 14.99f, 12.66f, 14.99f, 11f) + lineTo(15f, 5f) + curveTo(15f, 3.34f, 13.66f, 2f, 12f, 2f) + reflectiveCurveTo(9f, 3.34f, 9f, 5f) + verticalLineToRelative(6f) + curveTo(9f, 12.66f, 10.34f, 14f, 12f, 14f) + close() + moveTo(17.3f, 11f) + curveTo(17.3f, 14f, 14.76f, 16.1f, 12f, 16.1f) + reflectiveCurveTo(6.7f, 14f, 6.7f, 11f) + horizontalLineTo(5f) + curveTo(5f, 14.41f, 7.72f, 17.23f, 11f, 17.72f) + verticalLineTo(21f) + horizontalLineTo(8f) + verticalLineTo(23f) + horizontalLineTo(16f) + verticalLineTo(21f) + horizontalLineTo(13f) + verticalLineToRelative(-3.28f) + curveTo(16.28f, 17.24f, 19f, 14.42f, 19f, 11f) + horizontalLineToRelative(-1.7f) + close() + } + }.build() + } + + val Stop: ImageVector by lazy { + ImageVector.Builder( + name = "Stop", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ).apply { + path(fill = SolidColor(Color.Black)) { + moveTo(6f, 6f) + horizontalLineTo(18f) + verticalLineTo(18f) + horizontalLineTo(6f) + close() + } + }.build() + } + + val DeleteOutline: ImageVector by lazy { + ImageVector.Builder( + name = "DeleteOutline", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ).apply { + path(fill = SolidColor(Color.Black)) { + moveTo(6f, 19f) + curveTo(6f, 20.1f, 6.9f, 21f, 8f, 21f) + horizontalLineTo(16f) + curveTo(17.1f, 21f, 18f, 20.1f, 18f, 19f) + verticalLineTo(7f) + horizontalLineTo(6f) + verticalLineTo(19f) + close() + moveTo(8f, 9f) + horizontalLineTo(16f) + verticalLineTo(19f) + horizontalLineTo(8f) + verticalLineTo(9f) + close() + moveTo(15.5f, 4f) + lineTo(14.5f, 3f) + horizontalLineTo(9.5f) + lineTo(8.5f, 4f) + horizontalLineTo(5f) + verticalLineTo(6f) + horizontalLineTo(19f) + verticalLineTo(4f) + horizontalLineTo(15.5f) + close() + } + }.build() + } +} diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt index 965eae5..7c3ba99 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt @@ -61,7 +61,7 @@ class AndroidV10PlanningMigrationTest { TrainlogRepository(context, name).useForTest { it.listSessions() } SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db -> - assertEquals(13, scalar(db, "PRAGMA user_version")) + assertEquals(15, scalar(db, "PRAGMA user_version")) for (table in listOf("session_exercises", "draft_session_exercises")) { assertEquals(6, scalar(db, "SELECT COUNT(*) FROM pragma_table_info('$table') WHERE name IN ('load_mode','rest_seconds','target_sets','target_reps','target_duration_seconds','target_weight_kg')")) assertEquals(0, scalar(db, "SELECT COUNT(*) FROM $table WHERE load_mode<>'none' OR rest_seconds<>0 OR target_sets IS NOT NULL OR target_reps IS NOT NULL OR target_duration_seconds IS NOT NULL OR target_weight_kg IS NOT NULL")) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV11ExerciseAliasMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV11ExerciseAliasMigrationTest.kt index 0e15851..3b6c8be 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV11ExerciseAliasMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV11ExerciseAliasMigrationTest.kt @@ -64,7 +64,7 @@ class AndroidV11ExerciseAliasMigrationTest { migrated.close() } SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db -> - assertEquals(13, scalar(db, "PRAGMA user_version")) + assertEquals(15, 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 != "exercise_aliases" } diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt index 7e0b341..128e8b0 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt @@ -323,7 +323,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(13, cursor.getInt(0)) + assertTrue(cursor.moveToFirst()); assertEquals(15, cursor.getInt(0)) } db.rawQuery("SELECT id FROM exercises", null).use { cursor -> assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0)) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/FeedbackElapsedLabelTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/FeedbackElapsedLabelTest.kt new file mode 100644 index 0000000..7b1650d --- /dev/null +++ b/android/app/src/test/java/com/labfytools/trainlog/data/FeedbackElapsedLabelTest.kt @@ -0,0 +1,23 @@ +package com.labfytools.trainlog.data +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +class FeedbackElapsedLabelTest { + @Test fun `whole hours floor and never switch to days`() { + val end="2026-09-01T10:00:00+02:00" + listOf(0 to "2026-09-01T10:59:59+02:00",8 to "2026-09-01T18:00:00+02:00",23 to "2026-09-02T09:59:59+02:00",47 to "2026-09-03T09:00:00+02:00",72 to "2026-09-04T10:00:00+02:00").forEach{(h,t)->assertEquals("H+$h",sessionFollowUpElapsedLabel(end,t))} + } + @Test fun `unknown followup anchor is explicit`()=assertEquals("H+?",sessionFollowUpElapsedLabel(null,"2026-09-01T18:00:00+02:00")) + @Test fun `exercise feedback distinguishes session time after time and unknown`() { + val end="2026-09-01T10:00:00+02:00" + assertEquals("Pendant la séance",exerciseFeedbackElapsedLabel(end,"2026-09-01T09:20:00+02:00")) + assertEquals("Pendant la séance",exerciseFeedbackElapsedLabel(end,"2026-09-01T09:59:59+02:00")) + assertEquals("H+0",exerciseFeedbackElapsedLabel(end,end)) + assertEquals("H+8",exerciseFeedbackElapsedLabel(end,"2026-09-01T18:00:00+02:00")) + assertEquals("Ressenti",exerciseFeedbackElapsedLabel(null,"2026-09-01T09:20:00+02:00")) + } + @Test fun `helpers never render negative H plus`() { + val labels=listOf(exerciseFeedbackElapsedLabel("2026-09-01T10:00:00+02:00","2026-09-01T09:00:00+02:00"),sessionFollowUpElapsedLabel("2026-09-01T10:00:00+02:00","2026-09-01T09:00:00+02:00")) + assertTrue(labels.none{it.startsWith("H+-")}) + } +} diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/RealAndroidV12MachineExerciseMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/RealAndroidV12MachineExerciseMigrationTest.kt index 126c6e2..38d7a38 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/RealAndroidV12MachineExerciseMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/RealAndroidV12MachineExerciseMigrationTest.kt @@ -43,6 +43,46 @@ class RealAndroidV12MachineExerciseMigrationTest { context.deleteDatabase(databaseName) } + @Test + fun realVersionThirteenCopyUsesProductionMigrationPreservesFactsAndIsIdempotent() { + val source = File(System.getenv("TRAINLOG_ANDROID_V13_FIXTURE") ?: "") + val output = File(System.getenv("TRAINLOG_ANDROID_V14_OUTPUT") ?: "") + assumeTrue("TRAINLOG_ANDROID_V13_FIXTURE is not available", source.isFile) + assumeTrue("TRAINLOG_ANDROID_V14_OUTPUT is not available", output.parentFile?.isDirectory == true) + val databasePath = context.getDatabasePath(databaseName) + databasePath.parentFile?.mkdirs() + Files.copy(source.toPath(), databasePath.toPath(), StandardCopyOption.REPLACE_EXISTING) + val before = historicalDump(databasePath) + val beforeDraftCounts = SQLiteDatabase.openDatabase(databasePath.path, null, SQLiteDatabase.OPEN_READONLY).use { database -> + scalarInt(database, "SELECT COUNT(*) FROM active_session_draft;") to + scalarInt(database, "SELECT COUNT(*) FROM draft_session_exercises;") + } + SQLiteDatabase.openDatabase(databasePath.path, null, SQLiteDatabase.OPEN_READONLY).use { database -> + assertEquals(13, scalarInt(database, "PRAGMA user_version;")) + assertEquals("ok", scalarString(database, "PRAGMA integrity_check;")) + } + TrainlogRepository(context, databaseName).let { repository -> + try { assertTrue(repository.listExercises().isNotEmpty()) } finally { repository.close() } + } + assertValidVersionThirteen(databasePath) + assertEquals(before, historicalDump(databasePath, before.keys)) + SQLiteDatabase.openDatabase(databasePath.path, null, SQLiteDatabase.OPEN_READONLY).use { database -> + assertEquals(0, scalarInt(database, "SELECT COUNT(*) FROM exercise_feedback;")) + assertEquals(0, scalarInt(database, "SELECT COUNT(*) FROM draft_exercise_feedback;")) + assertEquals(0, scalarInt(database, "SELECT COUNT(*) FROM session_followups;")) + assertEquals(beforeDraftCounts.first, scalarInt(database, "SELECT COUNT(*) FROM active_session_draft;")) + assertEquals(beforeDraftCounts.second, scalarInt(database, "SELECT COUNT(*) FROM draft_session_exercises;")) + /* No historical value is synthesized by the nullable new column. */ + assertEquals(0, scalarInt(database, "SELECT COUNT(*) FROM sessions WHERE ended_at IS NOT NULL;")) + } + val first = logicalDump(databasePath) + TrainlogRepository(context, databaseName).let { repository -> + try { assertTrue(repository.listExercises().isNotEmpty()) } finally { repository.close() } + } + assertEquals(first, logicalDump(databasePath)) + Files.copy(databasePath.toPath(), output.toPath(), StandardCopyOption.REPLACE_EXISTING) + } + @Test fun realVersionTwelveCopyUsesProductionMigrationAndIsIdempotent() { val source = File(System.getenv("TRAINLOG_ANDROID_V12_FIXTURE") ?: "") @@ -85,7 +125,7 @@ class RealAndroidV12MachineExerciseMigrationTest { private fun assertValidVersionThirteen(databasePath: File) { SQLiteDatabase.openDatabase(databasePath.path, null, SQLiteDatabase.OPEN_READONLY).use { database -> - assertEquals(13, scalarInt(database, "PRAGMA user_version;")) + assertEquals(15, scalarInt(database, "PRAGMA user_version;")) assertEquals("ok", scalarString(database, "PRAGMA integrity_check;")) database.rawQuery("PRAGMA foreign_key_check;", null).use { assertFalse(it.moveToFirst()) } } @@ -115,6 +155,26 @@ class RealAndroidV12MachineExerciseMigrationTest { } } + /** Snapshot every original column so additive columns/tables are ignored + * while every pre-migration user fact remains byte-for-byte comparable. */ + private fun historicalDump(databasePath: File, shape: Set? = null): Map> = + SQLiteDatabase.openDatabase(databasePath.path, null, SQLiteDatabase.OPEN_READONLY).use { database -> + val tables = database.rawQuery("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name;", null) + .use { c -> buildList { while(c.moveToNext()) add(c.getString(0)) } } + .filter { it != "android_metadata" } + val specifications = shape ?: tables.map { table -> + val columns = database.rawQuery("PRAGMA table_info(`$table`);", null) + .use { c -> buildList { while(c.moveToNext()) add(c.getString(1)) } } + "$table|${columns.joinToString(",")}" + }.toSet() + specifications.associateWith { specification -> + val table = specification.substringBefore('|') + val columns = specification.substringAfter('|').split(',') + database.rawQuery("SELECT ${columns.joinToString(",") { "`$it`" }} FROM `$table` ORDER BY rowid;",null) + .use { c -> buildList { while(c.moveToNext()) add(buildString { repeat(c.columnCount){i->if(i>0)append('|');append(if(c.isNull(i))"" else c.getString(i))} }) } } + } + } + private fun scalarInt(database: SQLiteDatabase, sql: String): Int = database.rawQuery(sql, null).use { cursor -> assertTrue(cursor.moveToFirst()) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/RealAndroidV14FeedbackRevisionMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/RealAndroidV14FeedbackRevisionMigrationTest.kt new file mode 100644 index 0000000..681fd5c --- /dev/null +++ b/android/app/src/test/java/com/labfytools/trainlog/data/RealAndroidV14FeedbackRevisionMigrationTest.kt @@ -0,0 +1,48 @@ +package com.labfytools.trainlog.data + +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assume.assumeTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.File +import java.nio.file.Files +import java.nio.file.StandardCopyOption + +/** Optional copy-only gate for the current daily v14 database. The fixture is + * never opened by Trainlog: only a second test-owned copy is migrated. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class RealAndroidV14FeedbackRevisionMigrationTest { + @Test fun freshRealCopyMigratesLosslesslyToVersionFifteen() { + val source=File(System.getenv("TRAINLOG_ANDROID_V14_FIXTURE")?:"") + val output=File(System.getenv("TRAINLOG_ANDROID_V15_OUTPUT")?:"") + assumeTrue(source.isFile);assumeTrue(output.parentFile?.isDirectory==true) + val context=ApplicationProvider.getApplicationContext();val name="real-v14-feedback-copy.db" + val target=context.getDatabasePath(name);target.parentFile?.mkdirs();Files.copy(source.toPath(),target.toPath(),StandardCopyOption.REPLACE_EXISTING) + val before=SQLiteDatabase.openDatabase(target.path,null,SQLiteDatabase.OPEN_READONLY).use{db-> + assertEquals(14,scalar(db,"PRAGMA user_version"));assertEquals(1,scalar(db,"SELECT COUNT(*) FROM active_session_draft"));assertEquals(6,scalar(db,"SELECT COUNT(*) FROM draft_session_exercises")) + val ids=values(db,"SELECT entry_id FROM draft_session_exercises ORDER BY entry_id") + val roots=values(db,"SELECT feedback_id||'|'||observed_at||'|'||raw_text FROM draft_exercise_feedback ORDER BY feedback_id") + ids to roots + } + TrainlogRepository(context,name).let { repository -> repository.listExercises(); repository.close() } + SQLiteDatabase.openDatabase(target.path,null,SQLiteDatabase.OPEN_READONLY).use{db-> + assertEquals(15,scalar(db,"PRAGMA user_version"));assertEquals("ok",string(db,"PRAGMA integrity_check"));db.rawQuery("PRAGMA foreign_key_check",null).use{assertFalse(it.moveToFirst())} + assertEquals(before.first,values(db,"SELECT entry_id FROM draft_session_exercises ORDER BY entry_id"));assertEquals(before.second,values(db,"SELECT feedback_id||'|'||observed_at||'|'||raw_text FROM draft_exercise_feedback ORDER BY feedback_id")) + assertEquals(before.second.size,scalar(db,"SELECT COUNT(*) FROM draft_exercise_feedback_revisions")) + assertEquals(0,scalar(db,"SELECT COUNT(*) FROM draft_exercise_feedback f LEFT JOIN draft_exercise_feedback_revisions r ON r.revision_id='fr0_'||f.feedback_id WHERE r.revision_id IS NULL")) + assertEquals(1,scalar(db,"SELECT COUNT(*) FROM draft_session_exercises de JOIN exercises e ON e.id=de.exercise_row_id WHERE de.entry_id='sxe_6603ec2a-fc48-493f-94e1-58e4ede43b41' AND e.name='Converting chest press' AND de.position=3 AND de.target_sets=3 AND de.target_reps=10 AND abs(de.target_weight_kg-31.2)<0.0001")) + assertEquals(3,scalar(db,"SELECT COUNT(*) FROM draft_performed_sets ps JOIN draft_session_exercises de ON de.id=ps.draft_exercise_row_id WHERE de.entry_id='sxe_6603ec2a-fc48-493f-94e1-58e4ede43b41' AND ps.reps=10 AND abs(ps.weight_kg-32.0)<0.0001")) + } + Files.copy(target.toPath(),output.toPath(),StandardCopyOption.REPLACE_EXISTING);context.deleteDatabase(name) + } + private fun scalar(db:SQLiteDatabase,sql:String)=db.rawQuery(sql,null).use{it.moveToFirst();it.getInt(0)} + private fun string(db:SQLiteDatabase,sql:String)=db.rawQuery(sql,null).use{it.moveToFirst();it.getString(0)} + private fun values(db:SQLiteDatabase,sql:String)=db.rawQuery(sql,null).use{c->buildList{while(c.moveToNext())add(c.getString(0))}} +} diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/TrainingFeedbackRepositoryTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/TrainingFeedbackRepositoryTest.kt new file mode 100644 index 0000000..dab5e89 --- /dev/null +++ b/android/app/src/test/java/com/labfytools/trainlog/data/TrainingFeedbackRepositoryTest.kt @@ -0,0 +1,125 @@ +package com.labfytools.trainlog.data + +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import androidx.test.core.app.ApplicationProvider +import com.labfytools.trainlog.model.* +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.UUID + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class TrainingFeedbackRepositoryTest { + private lateinit var context:Context;private lateinit var aName:String;private lateinit var bName:String + private lateinit var android:TrainlogRepository;private lateinit var peer:TrainlogRepository + @Before fun setUp(){context=ApplicationProvider.getApplicationContext();aName="feedback-a-${UUID.randomUUID()}.db";bName="feedback-b-${UUID.randomUUID()}.db";android=TrainlogRepository(context,aName);peer=TrainlogRepository(context,bName)} + @After fun tearDown(){android.close();peer.close();context.deleteDatabase(aName);context.deleteDatabase(bName)} + @Test fun `post sync addition unions and return converges without duplicates`() { + val created=android.createExercise(NewExerciseProfile("Fixture feedback",RecordingMode.SETS,TrackingMode.REPS,0)) as CreateExerciseResult.Created + val entry=SessionExerciseDraft(exercise=created.exercise,sets=listOf(SessionSetDraft(reps=8))) + val saved=android.saveSession(SessionDraft(listOf(entry))) as SaveSessionResult.Saved + assertTrue(android.saveExerciseFeedback(saved.sessionId,entry.entryId,"ressenti A","2026-09-01T10:01:00+02:00") is SaveFeedbackResult.Saved) + assertTrue(android.saveSessionFollowUp(saved.sessionId,"suivi A","2026-09-01T18:00:00+02:00") is SaveFeedbackResult.Saved) + assertTrue(android.applyTrainingFeedbackJson(android.buildTrainingFeedbackJson()) is TrainingFeedbackImportResult.Applied) + assertTrue(android.applyTrainingFeedbackJson(android.buildTrainingFeedbackJson()) is TrainingFeedbackImportResult.Applied) + assertEquals(1,android.listExerciseFeedback(saved.sessionId).size);assertEquals(1,android.listSessionFollowUps(saved.sessionId).size) + assertTrue(android.saveSessionFollowUp(saved.sessionId,"suivi B","2026-09-02T09:00:00+02:00") is SaveFeedbackResult.Saved) + assertTrue(android.applyTrainingFeedbackJson(android.buildTrainingFeedbackJson()) is TrainingFeedbackImportResult.Applied) + assertEquals(listOf("suivi A","suivi B"),android.listSessionFollowUps(saved.sessionId).map{it.rawText}) + assertEquals(2,android.listSessionFollowUps(saved.sessionId).size) + } + + @Test fun `draft feedback is durable ordered excluded from sync and transfers unchanged`() { + val created=android.createExercise(NewExerciseProfile("Fixture draft feedback",RecordingMode.SETS,TrackingMode.REPS,0)) as CreateExerciseResult.Created + val entry=SessionExerciseDraft(entryId="sxe_00000000-0000-4000-8000-0000000000d1",exercise=created.exercise,sets=listOf(SessionSetDraft(reps=8))) + assertEquals(ActiveDraftMutationResult.Saved,android.saveActiveSessionDraft(ActiveSessionDraft(exercises=listOf(entry)))) + val later=android.saveDraftExerciseFeedback(entry.entryId,"deuxième observation","2026-09-01T10:10:00+02:00") as SaveFeedbackResult.Saved + val first=android.saveDraftExerciseFeedback(entry.entryId,"première observation","2026-09-01T10:00:00+02:00") as SaveFeedbackResult.Saved + assertTrue(android.reviseDraftExerciseFeedback(first.stableId,"première observation corrigée") is SaveFeedbackResult.Saved) + assertEquals(listOf(first.stableId,later.stableId),android.listDraftExerciseFeedback(entry.entryId).map{it.feedbackId}) + /* A normal form/draft write rebuilds occurrence rows; feedback must + * remain attached by entry_id instead of depending on a transient rowid. */ + assertEquals(ActiveDraftMutationResult.Saved,android.saveActiveSessionDraft(ActiveSessionDraft(exercises=listOf(entry)))) + assertEquals(2,android.listDraftExerciseFeedback(entry.entryId).size) + assertEquals(0,JSONObject(android.buildTrainingFeedbackJson()).getJSONArray("exercise_feedback").length()) + + android.close() + android=TrainlogRepository(context,aName) + val reopened=android.listDraftExerciseFeedback(entry.entryId) + assertEquals(listOf("première observation corrigée","deuxième observation"),reopened.map{it.rawText}) + assertTrue(reopened.first().modified) + val saved=android.finalizeActiveSessionDraft() as FinalizeActiveDraftResult.Saved + assertTrue(android.listDraftExerciseFeedback(entry.entryId).isEmpty()) + val completed=android.getSessionDetail(saved.sessionId)!!.exercises.single().feedback + assertEquals(listOf(first.stableId,later.stableId),completed.map{it.feedbackId}) + assertEquals(reopened.map{it.observedAt},completed.map{it.observedAt}) + assertEquals(reopened.map{it.rawText},completed.map{it.rawText}) + assertEquals("Pendant la séance",exerciseFeedbackElapsedLabel( + android.getSessionDetail(saved.sessionId)!!.summary.endedAt,completed.first().observedAt)) + val exported=JSONObject(android.buildTrainingFeedbackJson()).getJSONArray("exercise_feedback") + assertEquals(2,exported.length()) + assertEquals(setOf(first.stableId,later.stableId),(0 until exported.length()).map{exported.getJSONObject(it).getString("feedback_id")}.toSet()) + val revised=(0 until exported.length()).map{exported.getJSONObject(it)}.single{it.getString("feedback_id")==first.stableId} + assertEquals(2,revised.getJSONArray("revisions").length()) + assertTrue(android.applyTrainingFeedbackJson(JSONObject(android.buildTrainingFeedbackJson()).toString()) is TrainingFeedbackImportResult.Applied) + assertEquals(2,JSONObject(android.buildTrainingFeedbackJson()).getJSONArray("exercise_feedback").let{items->(0 until items.length()).map{items.getJSONObject(it)}.single{it.getString("feedback_id")==first.stableId}.getJSONArray("revisions").length()}) + } + + @Test fun `draft discard cascades feedback without orphan`() { + val created=android.createExercise(NewExerciseProfile("Fixture discard feedback",RecordingMode.SETS,TrackingMode.REPS,0)) as CreateExerciseResult.Created + val entry=SessionExerciseDraft(exercise=created.exercise,sets=listOf(SessionSetDraft(reps=8))) + assertEquals(ActiveDraftMutationResult.Saved,android.saveActiveSessionDraft(ActiveSessionDraft(exercises=listOf(entry)))) + assertTrue(android.saveDraftExerciseFeedback(entry.entryId,"à abandonner") is SaveFeedbackResult.Saved) + assertEquals(ActiveDraftMutationResult.Saved,android.discardActiveSessionDraft()) + assertTrue(android.listDraftExerciseFeedback(entry.entryId).isEmpty()) + assertEquals(0,JSONObject(android.buildTrainingFeedbackJson()).getJSONArray("exercise_feedback").length()) + } + + @Test fun `feedback transfer failure rolls back completed session and preserves draft`() { + val created=android.createExercise(NewExerciseProfile("Fixture rollback feedback",RecordingMode.SETS,TrackingMode.REPS,0)) as CreateExerciseResult.Created + val entry=SessionExerciseDraft(exercise=created.exercise,sets=listOf(SessionSetDraft(reps=8))) + assertEquals(ActiveDraftMutationResult.Saved,android.saveActiveSessionDraft(ActiveSessionDraft(exercises=listOf(entry)))) + val feedback=android.saveDraftExerciseFeedback(entry.entryId,"doit survivre") as SaveFeedbackResult.Saved + android.close() + SQLiteDatabase.openDatabase(context.getDatabasePath(aName).path,null,SQLiteDatabase.OPEN_READWRITE).use { db -> + db.execSQL("CREATE TRIGGER fail_feedback_transfer BEFORE INSERT ON exercise_feedback BEGIN SELECT RAISE(ABORT,'synthetic transfer failure'); END;") + } + android=TrainlogRepository(context,aName) + assertTrue(android.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.DatabaseError) + assertTrue(android.listSessions().isEmpty()) + assertEquals(listOf(feedback.stableId),android.listDraftExerciseFeedback(entry.entryId).map{it.feedbackId}) + android.close() + SQLiteDatabase.openDatabase(context.getDatabasePath(aName).path,null,SQLiteDatabase.OPEN_READWRITE).use { db -> + db.execSQL("DROP TRIGGER fail_feedback_transfer;") + } + android=TrainlogRepository(context,aName) + assertTrue(android.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved) + assertEquals(1,android.listSessions().size) + assertEquals(listOf(feedback.stableId),android.listExerciseFeedback(android.listSessions().single().sessionId).map{it.feedbackId}) + } + + @Test fun `resumed max correction preserves completed feedback and adds draft feedback`() { + val created=android.createExercise(NewExerciseProfile("Fixture max correction",RecordingMode.SETS,TrackingMode.REPS,0)) as CreateExerciseResult.Created + val entry=SessionExerciseDraft(entryId="sxe_00000000-0000-4000-8000-0000000000f1",exercise=created.exercise,maxWeightKg=100.0) + val saved=android.saveSession(SessionDraft(listOf(entry),SessionType.MAX_TEST)) as SaveSessionResult.Saved + val original=android.saveExerciseFeedback(saved.sessionId,entry.entryId,"avant reprise","2026-09-01T10:00:00+02:00") as SaveFeedbackResult.Saved + assertEquals(ActiveDraftMutationResult.Saved,android.resumeMaxTestSession(saved.sessionId)) + val added=android.saveDraftExerciseFeedback(entry.entryId,"pendant reprise","2026-09-01T11:00:00+02:00") as SaveFeedbackResult.Saved + val resumed=(android.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft + assertEquals(ActiveDraftMutationResult.Saved,android.saveActiveSessionDraft( + resumed.copy(exercises=resumed.exercises.map{it.copy(maxWeightKg=101.0)}))) + val finalized=android.finalizeActiveSessionDraft() as FinalizeActiveDraftResult.Saved + assertEquals(saved.sessionId,finalized.sessionId) + val feedback=android.listExerciseFeedback(saved.sessionId) + assertEquals(listOf(original.stableId,added.stableId),feedback.map{it.feedbackId}) + assertEquals(listOf("avant reprise","pendant reprise"),feedback.map{it.rawText}) + assertEquals(101.0,android.getSessionDetail(saved.sessionId)!!.exercises.single().maxWeightKg!!,0.0) + } +} diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt index 1e6d3fd..152fa60 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt @@ -1651,7 +1651,7 @@ class TrainlogRepositoryDraftTest { ).use { db -> db.rawQuery("PRAGMA user_version;", null).use { cursor -> assertTrue(cursor.moveToFirst()) - assertEquals(13, cursor.getInt(0)) + assertEquals(15, cursor.getInt(0)) } db.rawQuery( "SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " + @@ -1778,7 +1778,7 @@ class TrainlogRepositoryDraftTest { ).use { db -> db.rawQuery("PRAGMA user_version;", null).use { cursor -> assertTrue(cursor.moveToFirst()) - assertEquals(13, cursor.getInt(0)) + assertEquals(15, cursor.getInt(0)) } db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor -> assertTrue(cursor.moveToFirst()) diff --git a/android/app/src/test/java/com/labfytools/trainlog/ui/AndroidSpeechRecognitionIntentTest.kt b/android/app/src/test/java/com/labfytools/trainlog/ui/AndroidSpeechRecognitionIntentTest.kt new file mode 100644 index 0000000..f60309a --- /dev/null +++ b/android/app/src/test/java/com/labfytools/trainlog/ui/AndroidSpeechRecognitionIntentTest.kt @@ -0,0 +1,25 @@ +package com.labfytools.trainlog.ui + +import android.speech.RecognizerIntent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +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 AndroidSpeechRecognitionIntentTest { + @Test fun `intent carries bounded best effort silence hints`() { + val intent=buildTrainlogRecognitionIntent("fr-FR",false) + assertEquals(RecognizerIntent.ACTION_RECOGNIZE_SPEECH,intent.action) + assertEquals(1500L,intent.getLongExtra( + RecognizerIntent.EXTRA_SPEECH_INPUT_COMPLETE_SILENCE_LENGTH_MILLIS,-1L)) + assertEquals(1100L,intent.getLongExtra( + RecognizerIntent.EXTRA_SPEECH_INPUT_POSSIBLY_COMPLETE_SILENCE_LENGTH_MILLIS,-1L)) + assertFalse(intent.hasExtra(RecognizerIntent.EXTRA_PREFER_OFFLINE)) + assertTrue(intent.getBooleanExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS,false)) + } +} diff --git a/android/app/src/test/java/com/labfytools/trainlog/ui/DraftFeedbackUiReadbackTest.kt b/android/app/src/test/java/com/labfytools/trainlog/ui/DraftFeedbackUiReadbackTest.kt new file mode 100644 index 0000000..1447f19 --- /dev/null +++ b/android/app/src/test/java/com/labfytools/trainlog/ui/DraftFeedbackUiReadbackTest.kt @@ -0,0 +1,74 @@ +package com.labfytools.trainlog.ui + +import android.content.Context +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.test.core.app.ApplicationProvider +import com.labfytools.trainlog.data.ActiveDraftMutationResult +import com.labfytools.trainlog.data.CreateExerciseResult +import com.labfytools.trainlog.data.FinalizeActiveDraftResult +import com.labfytools.trainlog.data.SaveFeedbackResult +import com.labfytools.trainlog.data.TrainlogRepository +import com.labfytools.trainlog.model.ActiveSessionDraft +import com.labfytools.trainlog.model.NewExerciseProfile +import com.labfytools.trainlog.model.RecordingMode +import com.labfytools.trainlog.model.SessionExerciseDraft +import com.labfytools.trainlog.model.SessionSetDraft +import com.labfytools.trainlog.model.TrackingMode +import com.labfytools.trainlog.ui.theme.TrainlogTheme +import java.util.UUID +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +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 DraftFeedbackUiReadbackTest { + @Suppress("DEPRECATION") + @get:Rule val compose = createComposeRule() + private lateinit var context: Context + private lateinit var databaseName: String + private lateinit var repository: TrainlogRepository + + @Before fun setUp() { + context = ApplicationProvider.getApplicationContext() + databaseName = "draft-feedback-ui-${UUID.randomUUID()}.db" + repository = TrainlogRepository(context, databaseName) + } + + @After fun tearDown() { + repository.close() + context.deleteDatabase(databaseName) + } + + @Test fun activeAndCompletedScreensReadBackTheSameFeedback() { + val exercise = (repository.createExercise(NewExerciseProfile( + "Tirage synthétique", RecordingMode.SETS, TrackingMode.REPS, 0, + )) as CreateExerciseResult.Created).exercise + val entry = SessionExerciseDraft(exercise = exercise, sets = listOf(SessionSetDraft(reps = 8))) + assertEquals(ActiveDraftMutationResult.Saved, + repository.saveActiveSessionDraft(ActiveSessionDraft(exercises = listOf(entry)))) + assertTrue(repository.saveDraftExerciseFeedback( + entry.entryId, "texte UI durable", "2026-09-01T10:00:00+02:00") is SaveFeedbackResult.Saved) + + val completedSessionId = mutableStateOf(null) + compose.setContent { TrainlogTheme { + completedSessionId.value?.let { SessionDetailScreen(repository, it, {}, {}) } + ?: SessionScreen(repository, 0, {}, {}, {}) + } } + compose.onNodeWithTag("draft-feedback-${entry.entryId}").fetchSemanticsNode() + compose.onNodeWithText("Ressenti · texte UI durable").fetchSemanticsNode() + + val saved = repository.finalizeActiveSessionDraft() as FinalizeActiveDraftResult.Saved + compose.runOnIdle { completedSessionId.value = saved.sessionId } + compose.onNodeWithText("Pendant la séance · texte UI durable").fetchSemanticsNode() + } +} diff --git a/android/app/src/test/java/com/labfytools/trainlog/ui/FeedbackDictationControllerTest.kt b/android/app/src/test/java/com/labfytools/trainlog/ui/FeedbackDictationControllerTest.kt new file mode 100644 index 0000000..7f82469 --- /dev/null +++ b/android/app/src/test/java/com/labfytools/trainlog/ui/FeedbackDictationControllerTest.kt @@ -0,0 +1,47 @@ +package com.labfytools.trainlog.ui + +import org.junit.Assert.* +import org.junit.Test + +class FeedbackDictationControllerTest { + private class Fake(override var available:Boolean=true):SpeechRecognitionAdapter{ + var listener:SpeechRecognitionAdapter.Listener?=null;var stopped=false;var destroyed=false + override fun start(languageTag:String,listener:SpeechRecognitionAdapter.Listener){assertEquals("fr-FR",languageTag);this.listener=listener} + override fun stop(){stopped=true};override fun destroy(){destroyed=true} + } + @Test fun `partial final correction resume append stop and save`() { + val fake=Fake();val c=FeedbackDictationController(fake) + c.start(true);assertEquals(DictationPhase.Listening,c.state.phase) + fake.listener!!.onPartial("premier");assertEquals("premier",c.state.visibleText) + fake.listener!!.onPartial("premier segment");assertEquals("premier segment",c.state.visibleText) + fake.listener!!.onFinal("premier segment");assertEquals(DictationPhase.Editing,c.state.phase) + c.edit("segment corrigé");c.start(true);fake.listener!!.onPartial("suite");c.stop() + assertEquals("segment corrigé suite",c.state.visibleText) + assertEquals("segment corrigé suite",c.beginSaving()) + assertEquals(DictationPhase.Saving,c.state.phase) + } + @Test fun `long multiline partial survives immediate stop and resume appends`() { + val fake=Fake();val c=FeedbackDictationController(fake) + c.edit("ligne une\nligne deux corrigée") + c.start(true) + val partial="ligne trois très longue qui doit rester intégralement visible après l'arrêt" + fake.listener!!.onPartial(partial) + c.stop() + assertTrue(fake.stopped) + assertEquals("ligne une\nligne deux corrigée $partial",c.state.visibleText) + assertEquals(DictationPhase.Editing,c.state.phase) + c.start(true);fake.listener!!.onFinal("ligne quatre") + assertEquals("ligne une\nligne deux corrigée $partial ligne quatre",c.state.visibleText) + } + @Test fun `errors preserve text and manual entry remains usable`() { + val fake=Fake();val c=FeedbackDictationController(fake);c.edit("conservé") + c.start(false);assertEquals("conservé",c.state.visibleText);c.edit("manuel") + fake.available=false;c.start(true);assertEquals("manuel",c.state.visibleText) + fake.available=true;c.start(true);fake.listener!!.onPartial("partiel");fake.listener!!.onError("erreur") + assertEquals("manuel partiel",c.state.visibleText);c.cancel();assertEquals("",c.state.visibleText) + } + @Test fun `blank save forbidden and lifecycle destroys recognizer`() { + val fake=Fake();val c=FeedbackDictationController(fake) + assertNull(c.beginSaving());c.destroy();assertTrue(fake.destroyed) + } +} diff --git a/android/app/src/test/java/com/labfytools/trainlog/ui/FeedbackEditorVoiceUiTest.kt b/android/app/src/test/java/com/labfytools/trainlog/ui/FeedbackEditorVoiceUiTest.kt new file mode 100644 index 0000000..d671756 --- /dev/null +++ b/android/app/src/test/java/com/labfytools/trainlog/ui/FeedbackEditorVoiceUiTest.kt @@ -0,0 +1,51 @@ +package com.labfytools.trainlog.ui + +import android.Manifest +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertTextContains +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onAllNodesWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.labfytools.trainlog.ui.theme.TrainlogTheme +import org.junit.Rule +import org.junit.Test +import org.junit.Assert.assertTrue +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk=[35]) +class FeedbackEditorVoiceUiTest { + @Suppress("DEPRECATION") @get:Rule val compose=createComposeRule() + private class Fake:SpeechRecognitionAdapter { + override val available=true + lateinit var listener:SpeechRecognitionAdapter.Listener + var stopped=false + override fun start(languageTag:String,listener:SpeechRecognitionAdapter.Listener){this.listener=listener} + override fun stop(){stopped=true} + override fun destroy()=Unit + } + @Test fun micStopTranscriptAndResumeExposeAccessibleState() { + Shadows.shadowOf(RuntimeEnvironment.getApplication()).grantPermissions(Manifest.permission.RECORD_AUDIO) + val fake=Fake() + compose.setContent { TrainlogTheme { FeedbackEditor({null},{},recognizerFactory={fake}) } } + compose.onNodeWithContentDescription("Démarrer la dictée").assertIsDisplayed() + assertTrue(compose.onAllNodesWithContentDescription("Arrêter la dictée") + .fetchSemanticsNodes().isEmpty()) + compose.onNodeWithContentDescription("Démarrer la dictée").performClick() + compose.onNodeWithContentDescription("Arrêter la dictée").assertIsDisplayed() + val longText="première ligne dictée avec assez de mots pour envelopper\ndeuxième ligne conservée" + compose.runOnIdle{fake.listener.onPartial(longText)} + compose.onNodeWithText(longText).assertIsDisplayed() + compose.onNodeWithContentDescription("Arrêter la dictée").performClick() + compose.runOnIdle{assertTrue(fake.stopped)} + compose.onNodeWithTag("feedback-edit-transcript").assertTextContains(longText) + compose.onNodeWithContentDescription("Reprendre la dictée").assertIsDisplayed() + } +} diff --git a/docs/android.md b/docs/android.md index 0f6621c..4833fa1 100644 --- a/docs/android.md +++ b/docs/android.md @@ -11,7 +11,10 @@ The desktop remains the canonical long-term history and analytics store. ## Session exchange V3 Completed session occurrences persist an `entry_id`; it is never regenerated -for exchange. Android publishes `trainlog-mobile-export-v3.json` as the active +for exchange. Android schema v14 added feedback roots and completion `ended_at` +anchors; v15 adds immutable correction history and the separately versioned +`trainlog-training-feedback-v2.json` (while still reading V1). No audio is stored. Android publishes +`trainlog-mobile-export-v3.json` as the active desktop snapshot and imports `trainlog-pc-mobile-export-v3.json` after the PC catalogue. The artifact preserves occurrence order, continuous metrics, set weights and equipment. The legacy V1 contract remains separate and readable. @@ -135,6 +138,12 @@ additive v4 -> v11 chain preserves catalog, completed sessions/actuals, body observations and the draft while adding the shared equipment catalogue, occurrence-level equipment links and stable completed/draft `entry_id` values. Exactly one active draft is supported; it is separate from completed history. +Schema v14 also gives its occurrences durable Android-local +`draft_exercise_feedback`. The active-session **🎙 Ressenti** action saves there +immediately and reads all observations back in timestamp/ID order. Multiple +records are allowed. They survive process restart and transfer by stable +`entry_id`, without regenerated feedback IDs, in the atomic finalization +transaction. Explicit draft discard cascades this draft-owned data. Schema v9 adds explicit completed/draft MAX results. Schema v10 additively stores direct primary/secondary body-zone relations and their private sync baseline; the taxonomy itself remains the shared manifest asset. Schema v11 @@ -464,6 +473,8 @@ The user does not need a separate manual export step before synchronization. An active draft is never included in completed history, session detail or this snapshot. Synchronization continues to exchange completed data while the draft stays local; no draft fields were added to the frozen mobile artifact. +Training Feedback V1 likewise excludes `draft_exercise_feedback`; only its +exact transferred completed rows are exported after finalization. ## 10. PC catalog access @@ -725,3 +736,19 @@ Equipment Detail reads the shared V2 relation asset, shows verified possible exercises with French confidence/evidence status, and opens Exercise Detail. Exercise Detail shows exercise-owned muscles, movements, BODY ZONES, and compatible physical equipment. Custom equipment receives no inferred anatomy. +Saved exercise feedback and session follow-ups expose **Modifier**. The latest +text is prefilled, resumed dictation appends to it, and save creates an immutable +v15 revision without changing `observed_at` or the H+ label. +The feedback editor presents microphone and stop actions with accessible labels +and keeps the live transcript in a wrapped, multi-line area. Its speech intent +requests 1.5 seconds of complete silence (and 1.1 seconds of possibly complete +silence) before completion. These durations are best-effort hints: Android +recognition engines may ignore them. Trainlog neither fabricates results with a +local timer nor enters an automatic restart loop; natural completion preserves +the captured text, while an explicit Stop remains immediate. + +Every user-initiated destructive commit uses an action-specific confirmation. +Cancel and Back are safe, outside taps cannot confirm, and the destructive +button is explicit. Draft occurrence warnings name nonzero performed-set, +continuous, MAX and feedback children; series deletion and whole-draft abandon +use the same gate. diff --git a/docs/architecture.md b/docs/architecture.md index dbd66f7..946e53b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -168,7 +168,9 @@ silently rendered as unclassified. ### Desktop -Desktop SQLite schema v13 is canonical long-term history. Its v9 -> v10 +Desktop SQLite schema v15 is canonical long-term history. Its v14 -> v15 +additive step introduces immutable wording-revision tables for the subjective +feedback roots introduced in v14; see [Training feedback](training_feedback.md). Its v9 -> v10 migration losslessly rebuilds only `performed_sets` so actual `weight_kg` may be finite `>= 0`; the column already existed and targets/max results retain their strictly-positive contracts. `session_exercises` @@ -203,7 +205,7 @@ exercise_body_zone_sync ### Android -Android has an independent local SQLite schema, currently v13. Completed and +Android has an independent local SQLite schema, currently v14. Completed and draft MAX values use one-to-one `max_results` and `draft_max_results` rows; resuming a completed Test max records its stable source session in the one durable draft. diff --git a/docs/current_state.md b/docs/current_state.md index 1b6335f..9dfd053 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -81,7 +81,7 @@ Implemented: F7 actions, and restored focus after overlays/routes); - UTF-8 cell-aware scrolling training-knowledge screen, tested at the 72x20 minimum terminal; -- SQLite schema v13, with stable ordered `session_exercises.entry_id`, +- SQLite schema v15, with stable ordered `session_exercises.entry_id`, occurrence-level equipment identity, and desktop-local custom-equipment definitions, plus occurrence-owned `max_results`; its v9 -> v10 migration rebuilds only `performed_sets` to permit explicit zero actual loads while @@ -133,6 +133,8 @@ Implemented: - `BODY_FOCUS_HOME_V1`: a compact accessible Home front/back BODY ZONES map, textual top-three exposure ranking and Catalogue/Statistics drill-through; - explicit confirmed discard and atomic completed-save/draft-clear; +- schema v15 immutable feedback wording revisions, Android correction UX, + read-only TUI display, and Training Feedback V2 synchronization; - exercise creation; - stable-ID exercise rename/editing with referenced-profile protection; - primary/secondary body-zone selection, display, hierarchy filtering and @@ -185,7 +187,7 @@ The implemented read-only training-knowledge layer loads shared versioned JSON catalogs as the sole authored scientific source, generates the immutable C catalog representation, and loads the same assets on Android. It has no database migration, no auto-seeding, and no synchronization artifact. The -desktop and Android databases are schema v13. Schema v13 is additive: exercise +desktop and Android databases are schema v15. Schema v13 is additive: exercise rows own machine-facing load semantics, optional legacy equipment provenance, an optional scientific profile, and an explicit resolved/unresolved science state. Legacy equipment tables and occurrence columns remain intact. diff --git a/docs/database.md b/docs/database.md index 529d763..7b5e426 100644 --- a/docs/database.md +++ b/docs/database.md @@ -16,7 +16,7 @@ is a later, separately versioned migration. ## 1. Status ```text -TRAINLOG_DATABASE_SCHEMA_VERSION=12 +TRAINLOG_DATABASE_SCHEMA_VERSION=15 DATABASE_SCHEMA_V12=PASS TRAINLOG_FORMAT_V1=FROZEN ``` @@ -90,6 +90,32 @@ direct zones, and rejects profile or primary-zone conflicts before mutation. A schema fixture must represent the real historical structure. Rewriting only `user_version` is not an acceptable migration test. +Schema v14 adds `exercise_feedback` and `session_followups` without rewriting +v13 facts. Both use stable immutable IDs, exact timestamps, non-empty bounded +text at API boundaries, and parent-owned cascading foreign keys to +occurrence/session owners. There is no direct feedback delete operation; +parent cascade preserves existing correction/deletion semantics. Android +additionally gains nullable `sessions.ended_at`; migration does +not populate it for old rows. +Desktop occurrence replacement preserves feedback for logical occurrences +whose `entry_id` remains in the same session: it snapshots and remaps the rows +inside the replacement transaction. Removed entries cascade, new entries do +not inherit observations, and the stable `sessions` row—and therefore every +session follow-up—remains unchanged. +On Android, v14 additionally creates `draft_exercise_feedback`, owned by +`draft_session_exercises` with `ON DELETE CASCADE`. Its stable `feedback_id`, +timestamp and raw text transfer to the completed table by occurrence `entry_id` +inside the existing finalization transaction. It is never synchronized while +the parent session remains a draft. + +Schema v15 is additive. `exercise_feedback_revisions`, Android-only +`draft_exercise_feedback_revisions`, and `session_followup_revisions` retain +immutable wording revisions. Every v14 root receives the deterministic initial +identity `fr0_` with `created_at=observed_at`; corrections use +`fr_`. Root `raw_text` remains only a transactionally updated +current-text cache. The authoritative current revision is selected by parsed +`created_at` instant, then bytewise `revision_id` on ties. + ## 3. Connection invariants Every connection enables: diff --git a/docs/reviews/training_feedback_v1_ux_safety_audit.md b/docs/reviews/training_feedback_v1_ux_safety_audit.md new file mode 100644 index 0000000..262bcf8 --- /dev/null +++ b/docs/reviews/training_feedback_v1_ux_safety_audit.md @@ -0,0 +1,39 @@ +# Training Feedback V1 — UX and destructive-action audit + +Repository audit performed for the schema-v15 closeout. “No action” means the +current product exposes no such user operation; no functionality was invented. + +| Action | Client | Persisted data affected | Confirmation before this tranche | Required correction / resulting gate | +|---|---|---|---|---| +| Remove active-draft occurrence | Android | occurrence, performed sets/continuous/MAX, plan and feedback roots/revisions | No | Shared action-specific modal with exact nonzero child counts | +| Remove one performed set | Android | one durable draft performed-set fact | No | Shared series-specific modal | +| Discard active draft/session | Android | complete durable draft graph | Yes, inline second action | Replaced by non-dismissible-outside shared modal; Back/Cancel safe | +| Change occurrence to incompatible profile | Android | existing actuals/MAX/plan may be replaced | No | Loss-specific modal; same `entry_id` and feedback retained | +| Detach/replace completed occurrence equipment | Android | persisted equipment association | No | Association-loss modal before detach/replacement | +| Delete feedback directly | Android | feedback root/revisions | No action | None added; correction appends a revision | +| Delete custom exercise | Android | catalog/history | No reachable delete action | No correction | +| Delete custom legacy equipment | Android | catalog association | No catalog-delete action; completed association editor can detach | Detach/replace gate above | +| Delete body observation | Android | body observation | No reachable delete action | No correction | +| Merge/retire exercise | Android | catalog identity graph | No reachable action | No correction | +| Settings reset/clear | Android | database/settings | No reachable destructive reset | No correction | +| Remove session occurrence | TUI | transient/corrected occurrence graph and children | Yes, numeric confirmation | Modal state normalized to default Non; arrows/Tab focus; Enter explicit; Escape/q cancel | +| Remove performed set | TUI | one occurrence performed-set fact | No | Existing event loop now enters the same safe confirmation state | +| Abandon in-process session draft | TUI | complete process-owned draft | Yes, numeric confirmation | Default Non modal semantics; no `q` confirmation | +| Merge/retire exercise | TUI | source identity, occurrence ownership, equipment/zones | Yes | Existing impact preview and default-safe two-choice overlay retained | +| Completed-session correction removes occurrence | TUI | occurrence plus its child facts and feedback revisions | Indirectly through explicit correction/save workflow | Replacement stays transactional; revisions retained only for same `entry_id`; removal follows confirmed occurrence choice | +| Delete custom exercise/equipment | TUI | catalog rows | No reachable direct delete action | No correction | +| Delete body observation | TUI | body row | No reachable delete action | No correction | +| Settings reset/clear | TUI | database/settings | No reachable action | No correction | + +Generator proposal removal/discard and dirty-form abandon concern transient, +not-yet-persisted editor state. Existing keep/discard guards remain explicit; +they do not delete durable training facts. Synchronization cleanup of temporary +documents is system-owned publication rollback/housekeeping, not a user data +deletion action. + +Undo was rejected for occurrence deletion. Current persistence reconstructs +draft occurrence rows; a byte-for-byte logical restore would require capturing +and reinstating every root, revision, timestamp, child row and position through +a second durable transaction. A cosmetic Snackbar could not prove that +identity contract. The confirmation modal is therefore the truthful V1 safety +mechanism. diff --git a/docs/roadmap.md b/docs/roadmap.md index 97537c6..2c03b55 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -494,3 +494,9 @@ Future equipment identification may collect manufacturer, model, name, photo, pictogram, and notes to form a proposal which the user must verify before persistence. OCR/image recognition and a custom-equipment relation schema are not part of EQUIPMENT_KNOWLEDGE_V2. +## TRAINING_FEEDBACK_V1 + +Implemented for human review: schema v14, Android voice/manual append-only +capture and readback, desktop read-only consultation, and bidirectional strict +companion convergence. Automatic interpretation and recommendations remain +future scope. diff --git a/docs/sync_exchange.md b/docs/sync_exchange.md index 0e5ec9a..0862abc 100644 --- a/docs/sync_exchange.md +++ b/docs/sync_exchange.md @@ -2,7 +2,7 @@ ## Machine-exercise Phase 1 compatibility -Desktop and Android schema v13 retain mobile export V3, readable V1/V2 imports, +Desktop and Android schema v15 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 @@ -675,3 +675,7 @@ existing equipment. Mapped canonical exercise names are metadata owned by `exercise_id`: PC exports them and both import paths prevent stale mapped peer labels from restoring an old display name. This does not change TRAINLOG_FORMAT_V1. +Training observations use the independent direction-neutral +`trainlog-training-feedback-v2.json`. Its append-only revision union, strict identity +checks, ordering, bounds, and conflicts are specified in +[Training feedback](training_feedback.md). diff --git a/docs/tests.md b/docs/tests.md index 7978f30..b1997b1 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -699,3 +699,20 @@ EQUIPMENT_KNOWLEDGE_V2 validation covers relation envelopes, canonical UUID and equipment references, enum/source integrity, uniqueness and stable ordering, the unresolved Seated Leg invariant, generic/custom non-inference, and generated C/Android query parity for leg press, combo equipment, and chest-zone derivation. +`training_feedback_sync` covers first import, identical replay, post-sync +follow-up addition, PC export convergence, and immutable-ID conflict. Android +unit tests cover partial/final recognition, correction, resume/append, stop, +save/cancel, failure preservation, permission/unavailable fallback, lifecycle +destroy, and H+ flooring/unknown anchors. Draft-feedback coverage saves before +completion, reopens the repository, preserves multiple stable IDs and ordering +across ordinary draft rewrites, verifies active/completed Compose readback, +excludes the draft rows from sync, transfers them atomically on finalization, +rolls back on an injected insert failure, and checks discard cascading. The +real-v13-copy production migration test also requires the existing active draft +and all its occurrences to remain identical while the new draft-feedback table +starts empty and a second production open is logically idempotent. +`feedback_correction` reconstructs completed occurrence rowids while retaining, +reordering and changing the exercise context of stable entries; it verifies +exact feedback preservation, selective cascade on removal, no inheritance by a +new entry, unchanged follow-ups, and full rollback on injected reattachment +failure. Android additionally covers its bounded resumed-MAX replacement path. diff --git a/docs/training_feedback.md b/docs/training_feedback.md new file mode 100644 index 0000000..775b050 --- /dev/null +++ b/docs/training_feedback.md @@ -0,0 +1,108 @@ +# Training feedback V1/V2 + +`TRAINING_FEEDBACK_V1` collects subjective raw wording for later human review. +It never changes a session, load, MAX, plan, BODY ZONE, scientific profile, or +generator decision, and it performs no medical, sentiment, recovery, pain, or +anatomical inference. + +## Ownership and data model + +Android is the only creator. The desktop database stores synchronized rows +and the TUI displays them read-only; neither desktop surface offers add, edit, +delete, or dictation actions. Schema v14 adds only: + +- `exercise_feedback(feedback_id, session_exercise_row_id, observed_at, raw_text)`; +- Android-only `draft_exercise_feedback(feedback_id, draft_session_exercise_row_id, + observed_at, raw_text)`; +- `session_followups(followup_id, session_row_id, observed_at, raw_text)`. + +The foreign keys point at real occurrence/session rows and cascade only when +an existing parent-deletion workflow removes that owner; there is no direct +feedback deletion action. Public exercise +feedback derives `session_id`, `entry_id`, and the current canonical +`exercise_id` from the occurrence. IDs are UUIDv4-backed `fb_…` and `fu_…`. +Schema v15 keeps each root and adds immutable wording revisions. A correction +keeps the root ID, parent and original `observed_at`; it is not a new +physiological observation. Android exposes **Modifier** with current text +prefilled and resumed dictation appending to it. Cancel creates nothing. +Blank text and text exceeding 8192 UTF-8 bytes are rejected, never truncated. +Only synthetic wording belongs in source fixtures; real feedback is private +runtime data and must not be committed. + +`trainlog-training-feedback-v2.json` transports roots plus complete +`revisions[]`. Merge is an append-only union by revision ID; omission never +deletes and immutable disagreement is a hard conflict. V1 remains readable: +its `raw_text` becomes `fr0_` only if absent and can never erase a +later V2 revision. Draft revisions survive restart/reconstruction and transfer +unchanged on finalization. Retained `session_id` + `entry_id` corrections keep +all revisions; confirmed parent removal may cascade them. No direct feedback +delete action exists, and feedback never drives automatic training adaptation. + +## Dictation + +The Android editor exposes **Démarrer**, live partial text while listening, +**Stop**, editable text, **Reprendre**, **Enregistrer**, and **Annuler**. +Stopping never saves. Resume appends recognition to the manually corrected +committed text. Errors retain captured text. French `fr-FR` is the V1 default; +on-device recognition is preferred when supported, otherwise Android's normal +service is used. Permission denial or unavailable recognition leaves manual +entry fully usable. The adapter and persistence transport text only: Trainlog +does not create or retain PCM, WAV, compressed audio, service audio, or a +microphone temporary file. Destroying the UI releases recognizer callbacks and +resources. + +## Time presentation + +`observed_at` and `sessions.ended_at` are exact offset-aware Trainlog +timestamps. New Android completions persist their explicit finalization instant +as `ended_at`; old NULL anchors are not backfilled or derived from `started_at`. +Session follow-up uses `H+floor((observed-end)/3600)` including after 24 hours, +or `H+?` when the anchor is missing/invalid. Exercise feedback observed before +the end displays `Pendant la séance`; at/after end it uses H+, and without an +anchor it displays the neutral `Ressenti`. No negative H+ is rendered. Ordering +parses instants, then compares the stable ID bytewise; it never +uses lexical timestamp order or machine-local timezone. + +## Synchronization + +`trainlog-training-feedback-v1.json` has format +`trainlog-training-feedback`, version 1, `generated_at`, and bounded +`exercise_feedback`/`session_followups` arrays. It is a separate, +direction-neutral companion and does not alter mobile export V3, +`TRAINLOG_FORMAT_V1`, BODY ZONES, equipment, or alias companions. + +Import occurs after exercise definitions, aliases, machine identities, sessions, +entries, and equipment reconciliation. Missing parents fail; no phantom object +is created. Exercise claims resolve aliases and must equal the occurrence's +canonical identity. A missing stable record is inserted, an identical stable +record is an idempotent success, and any differing immutable field is a hard +conflict. Omission never deletes local rows. Consequently an Android follow-up +added after an earlier sync is unioned on the next run and the complete PC set +can converge back to Android without duplication. + +Exercise feedback entered from the active-session occurrence UI is committed +immediately to `draft_exercise_feedback`; it does not wait for session +completion. It survives navigation, activity/process recreation and repository +reopen. Ordinary draft rewrites preserve it by stable `entry_id`. Explicitly +discarding the occurrence or whole draft removes this parent-owned local data +through `ON DELETE CASCADE`; there is no independent delete UI. + +Finalization copies every draft observation to `exercise_feedback` in the same +transaction as the completed session, matching the completed occurrence by +`entry_id` and preserving `feedback_id`, `observed_at`, and `raw_text` exactly. +The subsequent draft deletion removes only draft storage. Any transfer failure +rolls the entire finalization back. + +Draft feedback is Android-local and absent from all V1 companions because it +does not yet own a completed `session_id`. After finalization, the same stable +records enter the normal bidirectional Training Feedback companion. + +Completed-session correction may reconstruct internal occurrence rows. Desktop +therefore snapshots feedback inside the correction transaction and reattaches +it only when the same `entry_id` remains in the same `session_id`; IDs, +timestamps and raw text are unchanged. Removed occurrences cascade their owned +feedback and new occurrences inherit nothing. The stable session row is not +recreated, so follow-ups remain untouched. Android has no general completed +training-session editor, but its bounded resumed-MAX correction path applies +the same rule before reconstructing occurrences. Any reattachment failure +rolls the complete correction back. diff --git a/docs/tui.md b/docs/tui.md index 72ea167..ca181af 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -272,6 +272,12 @@ Inside editable session exercise lists: ```text d delete selected exercise from the session + +These delete keys now open the existing event-loop-owned confirmation state; +they do not commit directly. Selection defaults to **Non**, Escape and `q` +cancel, and Enter commits only when the explicit destructive choice is focused. +Draft abandon follows the same rule. Exercise merge keeps its impact preview +and confirmation. Feedback wording remains read-only in the TUI. ``` A failed replacement rolls back completely. @@ -640,3 +646,7 @@ dispatch the one registered list-search action on supported lists. Equipment Detail lists V2 verified exercise options and confidence within the 72x20 layout. The generated core also exposes bounded reverse and BODY ZONE queries without changing Body Focus geometry or session generation. +Completed-session detail also consults synchronized exercise feedback and the +session follow-up timeline read-only. Exercise labels use `Pendant la séance`, +`H+xx`, or neutral `Ressenti`; follow-ups use `H+xx` or `H+?`. The TUI never +renders a negative H+ and has no V1 feedback mutation. diff --git a/tests/test_training_feedback_sync.py b/tests/test_training_feedback_sync.py new file mode 100644 index 0000000..b80c365 --- /dev/null +++ b/tests/test_training_feedback_sync.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +"""Real desktop migration and append-only feedback companion regression.""" +import json,sqlite3,subprocess,tempfile +from pathlib import Path +ROOT=Path(__file__).resolve().parents[1]; BUILD=ROOT/"build/tui/trainlog" +def run(*a):return subprocess.run(a,cwd=ROOT,text=True,capture_output=True,check=True) +def main(): + with tempfile.TemporaryDirectory() as td: + d=Path(td);db=d/"feedback.db" + con=sqlite3.connect(db);con.executescript("""PRAGMA foreign_keys=ON; + CREATE TABLE exercises(id INTEGER PRIMARY KEY,exercise_id TEXT UNIQUE,name TEXT,normalized_name TEXT,tracking_mode TEXT,recording_mode TEXT,data_fields INTEGER,science_state TEXT); + CREATE TABLE exercise_aliases(source_exercise_id TEXT PRIMARY KEY,canonical_exercise_id TEXT REFERENCES exercises(exercise_id)); + CREATE TABLE sessions(id INTEGER PRIMARY KEY,session_id TEXT UNIQUE,started_at TEXT,ended_at TEXT,session_type TEXT); + CREATE TABLE session_exercises(id INTEGER PRIMARY KEY,entry_id TEXT UNIQUE,session_row_id INTEGER REFERENCES sessions(id),exercise_row_id INTEGER REFERENCES exercises(id),recording_mode TEXT,data_fields INTEGER,position INTEGER,load_mode TEXT,rest_seconds INTEGER); + CREATE TABLE exercise_feedback(id INTEGER PRIMARY KEY,feedback_id TEXT UNIQUE,session_exercise_row_id INTEGER REFERENCES session_exercises(id),observed_at TEXT,raw_text TEXT); + CREATE TABLE session_followups(id INTEGER PRIMARY KEY,followup_id TEXT UNIQUE,session_row_id INTEGER REFERENCES sessions(id),observed_at TEXT,raw_text TEXT); + CREATE TABLE exercise_feedback_revisions(revision_id TEXT PRIMARY KEY,feedback_id TEXT REFERENCES exercise_feedback(feedback_id) ON DELETE CASCADE,created_at TEXT,raw_text TEXT); + CREATE TABLE session_followup_revisions(revision_id TEXT PRIMARY KEY,followup_id TEXT REFERENCES session_followups(followup_id) ON DELETE CASCADE,created_at TEXT,raw_text TEXT); + PRAGMA user_version=15;""");assert con.execute("pragma user_version").fetchone()[0]==15 + con.execute("insert into exercises(exercise_id,name,normalized_name,tracking_mode,recording_mode,data_fields,science_state) values('ex_11111111-1111-4111-8111-111111111111','Plate Loaded Leg Press','fixture press','reps','sets',0,'unresolved')") + con.execute("insert into sessions(session_id,started_at,ended_at,session_type) values('se_11111111-1111-4111-8111-111111111111','2026-09-01T09:00:00+02:00','2026-09-01T10:00:00+02:00','training')") + con.execute("insert into session_exercises(entry_id,session_row_id,exercise_row_id,recording_mode,data_fields,position,load_mode,rest_seconds) values('sxe_fixture',(select id from sessions),(select id from exercises),'sets',0,0,'none',0)");con.commit();con.close() + p={"format":"trainlog-training-feedback","version":1,"generated_at":"2026-09-01T18:00:00+02:00","exercise_feedback":[{"feedback_id":"fb_22222222-2222-4222-8222-222222222222","session_id":"se_11111111-1111-4111-8111-111111111111","entry_id":"sxe_fixture","exercise_id":"ex_11111111-1111-4111-8111-111111111111","observed_at":"2026-09-01T10:01:00+02:00","raw_text":"ressenti synthétique"}],"session_followups":[{"followup_id":"fu_33333333-3333-4333-8333-333333333333","session_id":"se_11111111-1111-4111-8111-111111111111","observed_at":"2026-09-01T18:00:00+02:00","raw_text":"suivi A"}]};artifact=d/"in.json";artifact.write_text(json.dumps(p),encoding="utf8") + assert "PASS" in run("python",ROOT/"tools/import_training_feedback.py",artifact,"--database",db).stdout + assert "roots_skipped=2" in run("python",ROOT/"tools/import_training_feedback.py",artifact,"--database",db).stdout + p["session_followups"].append({"followup_id":"fu_44444444-4444-4444-8444-444444444444","session_id":p["session_followups"][0]["session_id"],"observed_at":"2026-09-02T09:00:00+02:00","raw_text":"suivi B"});artifact.write_text(json.dumps(p),encoding="utf8");run("python",ROOT/"tools/import_training_feedback.py",artifact,"--database",db) + out=d/"out.json";run("python",ROOT/"tools/export_training_feedback.py",out,"--database",db);x=json.loads(out.read_text());assert x["version"]==2 and len(x["exercise_feedback"])==1 and len(x["session_followups"])==2 + revision={"revision_id":"fr_55555555-5555-4555-8555-555555555555","created_at":"2026-09-03T10:00:00+02:00","raw_text":"ressenti corrigé"};x["exercise_feedback"][0]["revisions"].append(revision);artifact.write_text(json.dumps(x),encoding="utf8") + run("python",ROOT/"tools/import_training_feedback.py",artifact,"--database",db);again=run("python",ROOT/"tools/import_training_feedback.py",artifact,"--database",db).stdout;assert "revisions_added=0" in again + con=sqlite3.connect(db);assert con.execute("select count(*) from exercise_feedback_revisions").fetchone()[0]==2;assert con.execute("select raw_text from exercise_feedback").fetchone()[0]=="ressenti corrigé";con.close() + x["exercise_feedback"][0]["revisions"][-1]["raw_text"]="conflit";artifact.write_text(json.dumps(x),encoding="utf8");bad=subprocess.run(["python",str(ROOT/"tools/import_training_feedback.py"),str(artifact),"--database",str(db)],capture_output=True);assert bad.returncode!=0 + print("TRAINING_FEEDBACK_SYNC_TEST=PASS") +if __name__=="__main__":main() diff --git a/tools/export_equipment_associations.py b/tools/export_equipment_associations.py index 0456494..528643f 100644 --- a/tools/export_equipment_associations.py +++ b/tools/export_equipment_associations.py @@ -27,7 +27,7 @@ def main(): args = parser.parse_args() connection = sqlite3.connect(args.database) try: - if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13): + if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15): raise ValueError("schema desktop v8 à v13 requis") known_equipment = load_supplied_equipment_ids(args.catalog) known_equipment.update(row[0] for row in connection.execute( diff --git a/tools/export_equipment_definitions.py b/tools/export_equipment_definitions.py index 4960409..35e78bd 100644 --- a/tools/export_equipment_definitions.py +++ b/tools/export_equipment_definitions.py @@ -23,7 +23,7 @@ def main(): connection = sqlite3.connect(args.database) connection.row_factory = sqlite3.Row try: - if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13): + if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15): raise ValueError("schema desktop v8 à v13 requis") equipment = [dict(row) for row in connection.execute( "SELECT equipment_id,display_name,label_name,equipment_type,load_semantics " diff --git a/tools/export_exercise_aliases.py b/tools/export_exercise_aliases.py index 38a153c..44986a7 100644 --- a/tools/export_exercise_aliases.py +++ b/tools/export_exercise_aliases.py @@ -15,7 +15,7 @@ def main(): args = parser.parse_args() con = sqlite3.connect(args.database) try: - if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13): + if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15): raise ValueError("schema desktop v12/v13 requis") rows = con.execute( "SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases " diff --git a/tools/export_exercise_body_zones.py b/tools/export_exercise_body_zones.py index 583b193..064c6f1 100644 --- a/tools/export_exercise_body_zones.py +++ b/tools/export_exercise_body_zones.py @@ -40,7 +40,7 @@ def main(): connection = sqlite3.connect(args.database) connection.row_factory = sqlite3.Row try: - if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13): + if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15): raise ValueError("schema desktop v11/v12/v13 requis") exercises = [] for exercise in connection.execute("SELECT id,exercise_id FROM exercises ORDER BY exercise_id"): diff --git a/tools/export_pc_catalog.py b/tools/export_pc_catalog.py index de44c29..af5d06b 100755 --- a/tools/export_pc_catalog.py +++ b/tools/export_pc_catalog.py @@ -67,7 +67,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): + if version not in (8, 9, 10, 11, 12, 13, 14, 15): raise SystemExit( "PC_CATALOG_EXPORT=FAIL " f"schema={version}" diff --git a/tools/export_pc_mobile.py b/tools/export_pc_mobile.py index de1d908..d3f6ce0 100644 --- a/tools/export_pc_mobile.py +++ b/tools/export_pc_mobile.py @@ -68,7 +68,7 @@ def main(): con.row_factory = sqlite3.Row try: schema_version = con.execute("PRAGMA user_version").fetchone()[0] - if schema_version not in (11, 12, 13) and not (args.version == 2 and schema_version == 10): + if schema_version not in (11, 12, 13, 14, 15) and not (args.version == 2 and schema_version == 10): raise ValueError("schema desktop v11/v12/v13 requis (v10 accepté pour export V2 explicite)") known_equipment = supplied_equipment_ids() canonical_names = load_exercise_names() diff --git a/tools/export_training_feedback.py b/tools/export_training_feedback.py new file mode 100644 index 0000000..7c6b591 --- /dev/null +++ b/tools/export_training_feedback.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +"""Export the bounded, direction-neutral TRAINING_FEEDBACK_V2 companion.""" +import argparse, datetime, json, sqlite3 +from pathlib import Path + +def instant(value): return datetime.datetime.fromisoformat(value.replace("Z", "+00:00")) + +def main(): + parser=argparse.ArgumentParser(); parser.add_argument("output",type=Path); parser.add_argument("--database",type=Path,required=True); args=parser.parse_args() + db=sqlite3.connect(args.database) + try: + if db.execute("PRAGMA user_version").fetchone()[0] != 15: raise ValueError("schema desktop v15 requis") + 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() + if len(roots)>4096 or len(followups)>4096: raise ValueError("trop d'observations") + exercise=[] + for row in roots: + revisions=db.execute("SELECT revision_id,created_at,raw_text FROM exercise_feedback_revisions WHERE feedback_id=?",(row[0],)).fetchall() + revisions.sort(key=lambda r:(instant(r[1]),r[0].encode())) + exercise.append(dict(zip(("feedback_id","session_id","entry_id","exercise_id","observed_at"),row)) | {"revisions":[dict(zip(("revision_id","created_at","raw_text"),r)) for r in revisions]}) + result_followups=[] + for row in followups: + revisions=db.execute("SELECT revision_id,created_at,raw_text FROM session_followup_revisions WHERE followup_id=?",(row[0],)).fetchall() + revisions.sort(key=lambda r:(instant(r[1]),r[0].encode())) + result_followups.append(dict(zip(("followup_id","session_id","observed_at"),row)) | {"revisions":[dict(zip(("revision_id","created_at","raw_text"),r)) for r in revisions]}) + exercise.sort(key=lambda v:(instant(v["observed_at"]),v["feedback_id"].encode())); result_followups.sort(key=lambda v:(instant(v["observed_at"]),v["followup_id"].encode())) + payload={"format":"trainlog-training-feedback","version":2,"generated_at":datetime.datetime.now().astimezone().isoformat(),"exercise_feedback":exercise,"session_followups":result_followups} + args.output.write_text(json.dumps(payload,ensure_ascii=False,separators=(",",":")),encoding="utf-8") + finally: db.close() + print(f"TRAINING_FEEDBACK_EXPORT=PASS version=2 exercise={len(exercise)} followups={len(result_followups)}") +if __name__=="__main__": main() diff --git a/tools/import_equipment_associations.py b/tools/import_equipment_associations.py index fc1a1e3..3ca3a3b 100644 --- a/tools/import_equipment_associations.py +++ b/tools/import_equipment_associations.py @@ -172,7 +172,7 @@ def main(): fail("clés extension équipement invalides") connection = sqlite3.connect(args.database) try: - if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13): + if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15): fail("schema desktop v8 à v13 requis") known = load_catalog(args.catalog) known.update(row[0] for row in connection.execute( diff --git a/tools/import_equipment_definitions.py b/tools/import_equipment_definitions.py index fe19979..7fd3f6d 100644 --- a/tools/import_equipment_definitions.py +++ b/tools/import_equipment_definitions.py @@ -60,7 +60,7 @@ def main(): definitions = validate(json.loads(args.artifact.read_text(encoding="utf-8")), supplied_ids(args.catalog)) connection = sqlite3.connect(args.database) try: - if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13): + if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15): fail("schema desktop v8 à v13 requis") imported = skipped = 0 # Validate every same-ID row before inserting any definition. diff --git a/tools/import_exercise_aliases.py b/tools/import_exercise_aliases.py index 06841de..f51643b 100644 --- a/tools/import_exercise_aliases.py +++ b/tools/import_exercise_aliases.py @@ -62,8 +62,8 @@ def main(): con = sqlite3.connect(args.database) try: con.execute("PRAGMA foreign_keys=ON") - if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13): - fail("schema desktop v12/v13 requis") + if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15): + fail("schema desktop v12/v13/v14/v15 requis") con.execute("BEGIN IMMEDIATE") for source, canonical in aliases: target = con.execute("SELECT id,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?", (canonical,)).fetchone() diff --git a/tools/import_exercise_body_zones.py b/tools/import_exercise_body_zones.py index f2c3e96..c5a9762 100644 --- a/tools/import_exercise_body_zones.py +++ b/tools/import_exercise_body_zones.py @@ -204,7 +204,7 @@ def main(): connection = sqlite3.connect(args.database) updated = skipped = kept_local = 0 try: - if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13): + if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15): raise ImportFailure("schema desktop v11/v12/v13 requis") connection.execute("PRAGMA foreign_keys=ON") connection.execute("BEGIN IMMEDIATE") diff --git a/tools/import_mobile_export.py b/tools/import_mobile_export.py index 49503d8..2eeed0a 100755 --- a/tools/import_mobile_export.py +++ b/tools/import_mobile_export.py @@ -820,9 +820,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): + if version not in (5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15): raise ImportFailure( - f"base desktop schema v5 à v13 attendue, version trouvée: {version}" + f"base desktop schema v5 à v15 attendue, version trouvée: {version}" ) diff --git a/tools/import_training_feedback.py b/tools/import_training_feedback.py new file mode 100644 index 0000000..f193075 --- /dev/null +++ b/tools/import_training_feedback.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Strict V1-compatible, append-only TRAINING_FEEDBACK_V2 desktop merger.""" +import argparse,json,re,sqlite3 +from datetime import datetime +from pathlib import Path +ID=lambda p:re.compile(r"^"+p+r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$") +FB,FU,FR=ID("fb_"),ID("fu_"),ID("fr_") +def fail(message): raise ValueError(message) +def instant(value): + if not isinstance(value,str) or "T" not in value or not re.search(r"(?:Z|[+-]\d\d:\d\d)$",value): fail("timestamp offset-aware invalide") + try:return datetime.fromisoformat(value.replace("Z","+00:00")) + except ValueError:fail("timestamp invalide") +def valid_text(value): + if not isinstance(value,str) or not value.strip() or len(value.encode())>8192: fail("texte vide ou supérieur à 8192 octets UTF-8") +def load(path): + if path.stat().st_size>40*1024*1024:fail("artifact trop volumineux") + def unique(pairs): + result={} + for key,value in pairs: + if key in result:fail("champ JSON dupliqué: "+key) + result[key]=value + return result + root=json.loads(path.read_text(encoding="utf-8"),object_pairs_hook=unique) + if set(root)!={"format","version","generated_at","exercise_feedback","session_followups"} or root["format"]!="trainlog-training-feedback" or type(root["version"]) is not int or root["version"] not in (1,2):fail("artifact feedback v1/v2 invalide") + instant(root["generated_at"]) + if not isinstance(root["exercise_feedback"],list) or not isinstance(root["session_followups"],list) or len(root["exercise_feedback"])>4096 or len(root["session_followups"])>4096:fail("tableau feedback hors borne") + return root +def revision_list(item,root_id,version): + if version==1: valid_text(item["raw_text"]); return [("fr0_"+root_id,item["observed_at"],item["raw_text"])] + values=item["revisions"] + if not isinstance(values,list) or not values or len(values)>4096:fail("révisions absentes/hors borne") + result=[];seen=set() + for revision in values: + if not isinstance(revision,dict) or set(revision)!={"revision_id","created_at","raw_text"}:fail("révision invalide") + rid=revision["revision_id"] + if not isinstance(rid,str) or not (FR.fullmatch(rid) or rid=="fr0_"+root_id) or rid in seen:fail("revision_id invalide/dupliqué") + seen.add(rid);instant(revision["created_at"]);valid_text(revision["raw_text"]);result.append((rid,revision["created_at"],revision["raw_text"])) + return result +def merge_revisions(db,table,parent_column,parent_id,revisions): + added=skipped=0 + for revision in revisions: + old=db.execute(f"SELECT {parent_column},created_at,raw_text FROM {table} WHERE revision_id=?",(revision[0],)).fetchone(); expected=(parent_id,revision[1],revision[2]) + if old is None:db.execute(f"INSERT INTO {table}(revision_id,{parent_column},created_at,raw_text) VALUES(?,?,?,?)",(revision[0],*expected));added+=1 + elif old==expected:skipped+=1 + else:fail("HARD CONFLICT "+revision[0]) + current=max(db.execute(f"SELECT revision_id,created_at,raw_text FROM {table} WHERE {parent_column}=?",(parent_id,)).fetchall(),key=lambda r:(instant(r[1]),r[0].encode())) + return added,skipped,current[2] +def main(): + parser=argparse.ArgumentParser();parser.add_argument("artifact",type=Path);parser.add_argument("--database",type=Path,required=True);args=parser.parse_args();root=load(args.artifact);db=sqlite3.connect(args.database) + roots_added=roots_skipped=revisions_added=revisions_skipped=0 + try: + db.execute("PRAGMA foreign_keys=ON") + if db.execute("PRAGMA user_version").fetchone()[0]!=15:fail("schema desktop v15 requis") + db.execute("BEGIN IMMEDIATE");seen=set() + for index,item in enumerate(root["exercise_feedback"]): + keys={"feedback_id","session_id","entry_id","exercise_id","observed_at","raw_text"} if root["version"]==1 else {"feedback_id","session_id","entry_id","exercise_id","observed_at","revisions"} + if not isinstance(item,dict) or set(item)!=keys:fail(f"exercise_feedback[{index}] invalide") + fid=item["feedback_id"] + if not isinstance(fid,str) or not FB.fullmatch(fid) or fid in seen:fail("feedback_id invalide/dupliqué") + seen.add(fid);instant(item["observed_at"]);occurrence=db.execute("SELECT se.id,e.exercise_id FROM session_exercises se JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id WHERE s.session_id=? AND se.entry_id=?",(item["session_id"],item["entry_id"])).fetchone() + if not occurrence:fail("session/entry introuvable") + canonical=db.execute("SELECT exercise_id FROM exercises WHERE exercise_id=? UNION ALL SELECT canonical_exercise_id FROM exercise_aliases WHERE source_exercise_id=? LIMIT 1",(item["exercise_id"],item["exercise_id"])).fetchone() + if not canonical or canonical[0]!=occurrence[1]:fail("exercise_id incompatible avec l'occurrence") + old=db.execute("SELECT session_exercise_row_id,observed_at FROM exercise_feedback WHERE feedback_id=?",(fid,)).fetchone();expected=(occurrence[0],item["observed_at"]);revisions=revision_list(item,fid,root["version"]) + if old is None:db.execute("INSERT INTO exercise_feedback(feedback_id,session_exercise_row_id,observed_at,raw_text) VALUES(?,?,?,?)",(fid,*expected,revisions[0][2]));roots_added+=1 + elif old==expected:roots_skipped+=1 + else:fail("HARD CONFLICT "+fid) + added,skipped,current=merge_revisions(db,"exercise_feedback_revisions","feedback_id",fid,revisions);revisions_added+=added;revisions_skipped+=skipped;db.execute("UPDATE exercise_feedback SET raw_text=? WHERE feedback_id=?",(current,fid)) + seen=set() + for index,item in enumerate(root["session_followups"]): + keys={"followup_id","session_id","observed_at","raw_text"} if root["version"]==1 else {"followup_id","session_id","observed_at","revisions"} + if not isinstance(item,dict) or set(item)!=keys:fail(f"session_followups[{index}] invalide") + fid=item["followup_id"] + if not isinstance(fid,str) or not FU.fullmatch(fid) or fid in seen:fail("followup_id invalide/dupliqué") + seen.add(fid);instant(item["observed_at"]);session=db.execute("SELECT id FROM sessions WHERE session_id=?",(item["session_id"],)).fetchone() + if not session:fail("session introuvable") + old=db.execute("SELECT session_row_id,observed_at FROM session_followups WHERE followup_id=?",(fid,)).fetchone();expected=(session[0],item["observed_at"]);revisions=revision_list(item,fid,root["version"]) + if old is None:db.execute("INSERT INTO session_followups(followup_id,session_row_id,observed_at,raw_text) VALUES(?,?,?,?)",(fid,*expected,revisions[0][2]));roots_added+=1 + elif old==expected:roots_skipped+=1 + else:fail("HARD CONFLICT "+fid) + added,skipped,current=merge_revisions(db,"session_followup_revisions","followup_id",fid,revisions);revisions_added+=added;revisions_skipped+=skipped;db.execute("UPDATE session_followups SET raw_text=? WHERE followup_id=?",(current,fid)) + db.commit() + except Exception:db.rollback();raise + finally:db.close() + print(f"TRAINING_FEEDBACK_IMPORT=PASS version={root['version']} roots_added={roots_added} roots_skipped={roots_skipped} revisions_added={revisions_added} revisions_skipped={revisions_skipped}") +if __name__=="__main__":main() diff --git a/tui/include/trainlog/database.h b/tui/include/trainlog/database.h index 45a6419..7ff6e57 100644 --- a/tui/include/trainlog/database.h +++ b/tui/include/trainlog/database.h @@ -11,10 +11,27 @@ #include "trainlog/model.h" #include "trainlog/status.h" -#define TRAINLOG_DATABASE_SCHEMA_VERSION 13 +#define TRAINLOG_DATABASE_SCHEMA_VERSION 15 typedef struct TrainlogDatabase TrainlogDatabase; +#define TRAINLOG_FEEDBACK_TEXT_MAX 8192U +#define TRAINLOG_FEEDBACK_VIEW_MAX 4096U +typedef struct TrainlogFeedbackView { + char stable_id[TRAINLOG_ID_MAX + 1U]; + char entry_id[TRAINLOG_ID_MAX + 1U]; /* empty for session follow-up */ + char observed_at[TRAINLOG_TIMESTAMP_MAX + 1U]; + char raw_text[TRAINLOG_FEEDBACK_TEXT_MAX + 1U]; +} TrainlogFeedbackView; + +/* CONTRACT: read-only chronological consultation; Android remains the sole + * V1 creator. Ties use the stable ID byte order. No SQLite resources escape. */ +TrainlogStatus trainlog_database_list_training_feedback( + TrainlogDatabase *database, const char *session_id, + TrainlogFeedbackView *exercise_feedback, size_t exercise_capacity, + size_t *exercise_count, TrainlogFeedbackView *followups, + size_t followup_capacity, size_t *followup_count); + /* Nestable read savepoints let composition services observe one database state * without exposing SQLite or issuing writes to application data. */ TrainlogStatus trainlog_database_read_snapshot_begin(TrainlogDatabase *database); @@ -633,9 +650,11 @@ TrainlogStatus trainlog_database_load_session_editable( * @brief Replace only the exercise/set contents of an existing session. * * The session row itself is preserved, so session_id, timestamps, session type, - * session notes and body_observations.session_row_id remain attached to the - * same row. The replacement is atomic: any error rolls the whole operation - * back. + * session notes, session follow-ups and body_observations.session_row_id remain + * attached to the same row. Exercise feedback is remapped only by retained + * entry_id within that session; removed entries cascade and new entries never + * inherit feedback. The replacement is atomic: any error rolls the whole + * operation back. */ TrainlogStatus trainlog_database_replace_session_exercises( TrainlogDatabase *database, diff --git a/tui/include/trainlog/timeutil.h b/tui/include/trainlog/timeutil.h index 59f238f..148d5f6 100644 --- a/tui/include/trainlog/timeutil.h +++ b/tui/include/trainlog/timeutil.h @@ -7,6 +7,7 @@ */ #include +#include #include "trainlog/status.h" @@ -20,4 +21,11 @@ TrainlogStatus trainlog_time_now_rfc3339( size_t output_size ); +/* CONTRACT: exercise observations before end use "Pendant la séance" and a + * missing anchor uses "Ressenti"; follow-ups use H+? in both cases. No caller + * can receive a negative H+ label. */ +TrainlogStatus trainlog_feedback_relative_label( + const char *ended_at, const char *observed_at, bool exercise_feedback, + char *output, size_t output_size); + #endif diff --git a/tui/meson.build b/tui/meson.build index 60e3105..17a69fc 100644 --- a/tui/meson.build +++ b/tui/meson.build @@ -558,6 +558,12 @@ test( args: [meson.project_source_root() / 'tests/test_exercise_alias_import.py'], ) +test( + 'training_feedback_sync', + python3_trainlog_tests, + args: [meson.project_source_root() / 'tests/test_training_feedback_sync.py'], +) + test_sync_direction = executable( 'test_sync_direction', 'tests/test_sync_direction.c', @@ -652,6 +658,22 @@ test_tui_workflows = executable( test('tui_workflows', test_tui_workflows) +test_feedback_labels = executable( + 'test_feedback_labels', + 'tests/test_feedback_labels.c', + dependencies: trainlog_core_dep, + c_args: strict_c_args, +) +test('feedback_labels', test_feedback_labels) + +test_feedback_correction = executable( + 'test_feedback_correction', + 'tests/test_feedback_correction.c', + dependencies: trainlog_core_dep, + c_args: strict_c_args, +) +test('feedback_correction', test_feedback_correction) + test_app_shell = executable( 'test_app_shell', 'tests/test_app_shell.c', diff --git a/tui/src/database.c b/tui/src/database.c index 18b6106..9cff06e 100644 --- a/tui/src/database.c +++ b/tui/src/database.c @@ -566,6 +566,47 @@ static const char *const MIGRATE_V12_TO_V13_SQL_C = "UPDATE exercises SET load_semantics=NULL,machine_variant=NULL,machine_provenance=NULL,scientific_profile_id=NULL,science_state='unresolved',legacy_equipment_id=NULL WHERE exercise_id='ex_617007f9-7420-4408-91b9-8ffb77900f13';" "PRAGMA user_version=13;COMMIT;"; +/* WHY: subjective observations have their own immutable chronology and must + * never be encoded as performed sets, notes, or inferred exercise metadata. + * CONTRACT: v14 is additive; exercise feedback belongs to one durable + * occurrence and follow-up belongs only to its session. Text byte bounds are + * enforced by import/application APIs so SQLite never silently truncates. + * INVARIANT: every v13 row and identity remains byte-for-byte untouched. */ +static const char *const MIGRATE_V13_TO_V14_SQL = + "BEGIN IMMEDIATE;" + "CREATE TABLE exercise_feedback(" + "id INTEGER PRIMARY KEY,feedback_id TEXT NOT NULL UNIQUE," + "session_exercise_row_id INTEGER NOT NULL REFERENCES session_exercises(id) ON DELETE CASCADE," + "observed_at TEXT NOT NULL,raw_text TEXT NOT NULL CHECK(length(trim(raw_text))>0));" + "CREATE INDEX exercise_feedback_timeline ON exercise_feedback(session_exercise_row_id,observed_at,feedback_id);" + "CREATE TABLE session_followups(" + "id INTEGER PRIMARY KEY,followup_id TEXT NOT NULL UNIQUE," + "session_row_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE," + "observed_at TEXT NOT NULL,raw_text TEXT NOT NULL CHECK(length(trim(raw_text))>0));" + "CREATE INDEX session_followups_timeline ON session_followups(session_row_id,observed_at,followup_id);" + "PRAGMA user_version=14;COMMIT;"; + +/* WHY: wording corrections must not overwrite the original wording. + * CONTRACT: v14 root raw_text remains a transactionally maintained current + * cache; immutable revisions are the history source of truth. INVARIANT: + * fr0_ is deterministic across peers and migration/V1 import is + * idempotent. */ +static const char *const MIGRATE_V14_TO_V15_SQL = + "BEGIN IMMEDIATE;" + "CREATE TABLE exercise_feedback_revisions(" + "revision_id TEXT PRIMARY KEY,feedback_id TEXT NOT NULL REFERENCES exercise_feedback(feedback_id) ON DELETE CASCADE," + "created_at TEXT NOT NULL,raw_text TEXT NOT NULL CHECK(length(trim(raw_text))>0));" + "CREATE INDEX exercise_feedback_revisions_current ON exercise_feedback_revisions(feedback_id,created_at,revision_id);" + "CREATE TABLE session_followup_revisions(" + "revision_id TEXT PRIMARY KEY,followup_id TEXT NOT NULL REFERENCES session_followups(followup_id) ON DELETE CASCADE," + "created_at TEXT NOT NULL,raw_text TEXT NOT NULL CHECK(length(trim(raw_text))>0));" + "CREATE INDEX session_followup_revisions_current ON session_followup_revisions(followup_id,created_at,revision_id);" + "INSERT INTO exercise_feedback_revisions(revision_id,feedback_id,created_at,raw_text) " + "SELECT 'fr0_'||feedback_id,feedback_id,observed_at,raw_text FROM exercise_feedback;" + "INSERT INTO session_followup_revisions(revision_id,followup_id,created_at,raw_text) " + "SELECT 'fr0_'||followup_id,followup_id,observed_at,raw_text FROM session_followups;" + "PRAGMA user_version=15;COMMIT;"; + static const char *const MIGRATE_V1_TO_V3_SQL = "BEGIN IMMEDIATE;" "ALTER TABLE sessions " @@ -986,6 +1027,80 @@ static TrainlogStatus read_single_int_pragma( : TRAINLOG_STATUS_DATABASE_ERROR; } +static TrainlogStatus list_feedback_query( + TrainlogDatabase *database, const char *sql, const char *session_id, + TrainlogFeedbackView *output, size_t capacity, size_t *output_count, + bool has_entry) +{ + sqlite3_stmt *statement = NULL; + size_t count = 0U; + int rc; + if (database == NULL || sql == NULL || session_id == NULL || output == NULL || + output_count == NULL || capacity == 0U) return TRAINLOG_STATUS_INVALID_ARGUMENT; + rc = sqlite3_prepare_v2(database->connection, sql, -1, &statement, NULL); + if (rc != SQLITE_OK || sqlite3_bind_text(statement, 1, session_id, -1, + SQLITE_TRANSIENT) != SQLITE_OK) { + (void)sqlite3_finalize(statement); return TRAINLOG_STATUS_DATABASE_ERROR; + } + while (count < capacity && (rc = sqlite3_step(statement)) == SQLITE_ROW) { + const unsigned char *stable = sqlite3_column_text(statement, 0); + const unsigned char *entry = has_entry ? sqlite3_column_text(statement, 1) : NULL; + const unsigned char *at = sqlite3_column_text(statement, has_entry ? 2 : 1); + const unsigned char *text = sqlite3_column_text(statement, has_entry ? 3 : 2); + if (stable == NULL || at == NULL || text == NULL || + strlen((const char *)text) > TRAINLOG_FEEDBACK_TEXT_MAX) { + (void)sqlite3_finalize(statement); return TRAINLOG_STATUS_DATABASE_ERROR; + } + (void)snprintf(output[count].stable_id, sizeof(output[count].stable_id), "%s", stable); + (void)snprintf(output[count].entry_id, sizeof(output[count].entry_id), "%s", + entry != NULL ? (const char *)entry : ""); + (void)snprintf(output[count].observed_at, sizeof(output[count].observed_at), "%s", at); + (void)snprintf(output[count].raw_text, sizeof(output[count].raw_text), "%s", text); + count += 1U; + } + if (rc != SQLITE_DONE && count < capacity) { + (void)sqlite3_finalize(statement); return TRAINLOG_STATUS_DATABASE_ERROR; + } + rc = sqlite3_finalize(statement); *output_count = count; + return rc == SQLITE_OK ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_DATABASE_ERROR; +} + +static int compare_feedback_view(const void *left_value, const void *right_value) +{ + const TrainlogFeedbackView *left = left_value; + const TrainlogFeedbackView *right = right_value; + TrainlogTimestampKey left_key, right_key; + int compared; + if (!trainlog_timestamp_parse(left->observed_at, strlen(left->observed_at), &left_key) || + !trainlog_timestamp_parse(right->observed_at, strlen(right->observed_at), &right_key)) + return strcmp(left->stable_id, right->stable_id); + compared = trainlog_timestamp_compare(&left_key, &right_key); + return compared != 0 ? compared : strcmp(left->stable_id, right->stable_id); +} + +TrainlogStatus trainlog_database_list_training_feedback( + TrainlogDatabase *database, const char *session_id, + TrainlogFeedbackView *exercise_feedback, size_t exercise_capacity, + size_t *exercise_count, TrainlogFeedbackView *followups, + size_t followup_capacity, size_t *followup_count) +{ + TrainlogStatus status = list_feedback_query(database, + "SELECT f.feedback_id,se.entry_id,f.observed_at,f.raw_text 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 " + "WHERE s.session_id=?1 ORDER BY f.observed_at COLLATE BINARY,f.feedback_id COLLATE BINARY", + session_id, exercise_feedback, exercise_capacity, exercise_count, true); + if (status != TRAINLOG_STATUS_OK) return status; + qsort(exercise_feedback, *exercise_count, sizeof(*exercise_feedback), compare_feedback_view); + status = list_feedback_query(database, + "SELECT f.followup_id,f.observed_at,f.raw_text FROM session_followups f " + "JOIN sessions s ON s.id=f.session_row_id WHERE s.session_id=?1 " + "ORDER BY f.observed_at COLLATE BINARY,f.followup_id COLLATE BINARY", + session_id, followups, followup_capacity, followup_count, false); + if (status == TRAINLOG_STATUS_OK) + qsort(followups, *followup_count, sizeof(*followups), compare_feedback_view); + return status; +} + static TrainlogStatus initialize_or_validate_schema( TrainlogDatabase *database, char *output_diagnostic, @@ -1082,7 +1197,7 @@ static TrainlogStatus initialize_or_validate_schema( status = execute_sql(database, MIGRATE_V9_TO_V10_SQL); } else if (version == 10) { status = TRAINLOG_STATUS_OK; - } else if (version == 11 || version == 12) { + } else if (version == 11 || version == 12 || version == 13 || version == 14) { status = TRAINLOG_STATUS_OK; } else { if (version == 1) { @@ -1231,6 +1346,12 @@ static TrainlogStatus initialize_or_validate_schema( status = execute_sql(database, MIGRATE_V12_TO_V13_SQL_C); } } + if (status == TRAINLOG_STATUS_OK && version < 14) { + status = execute_sql(database, MIGRATE_V13_TO_V14_SQL); + } + if (status == TRAINLOG_STATUS_OK && version < 15) { + status = execute_sql(database, MIGRATE_V14_TO_V15_SQL); + } if ( status != @@ -1239,7 +1360,7 @@ static TrainlogStatus initialize_or_validate_schema( set_open_diagnostic( output_diagnostic, output_diagnostic_capacity, - version == 0 ? "create schema v13" : "migrate database to schema v13", + version == 0 ? "create schema v15" : "migrate database to schema v15", database->connection, SQLITE_ERROR ); @@ -3723,6 +3844,63 @@ TrainlogStatus trainlog_database_replace_session_exercises( return TRAINLOG_STATUS_DATABASE_ERROR; } + /* WHY: replacement reconstructs internal occurrence rows, while feedback + * belongs to the stable logical occurrence. CONTRACT: retain immutable + * feedback only when the same entry_id remains inside this same session; + * removed entries lose their parent-owned rows and new entries inherit + * nothing. INVARIANT: the temporary snapshot and reattachment are inside + * this transaction, so every failure restores the original graph. */ + rc = sqlite3_exec( + database->connection, + "CREATE TEMP TABLE trainlog_correction_feedback(" + "feedback_id TEXT PRIMARY KEY,entry_id TEXT NOT NULL," + "observed_at TEXT NOT NULL,raw_text TEXT NOT NULL);", + NULL, NULL, NULL + ); + if (rc == SQLITE_OK) { + rc = sqlite3_prepare_v2( + database->connection, + "INSERT INTO trainlog_correction_feedback" + "(feedback_id,entry_id,observed_at,raw_text) " + "SELECT f.feedback_id,se.entry_id,f.observed_at,f.raw_text " + "FROM exercise_feedback AS f " + "JOIN session_exercises AS se " + "ON se.id=f.session_exercise_row_id " + "WHERE se.session_row_id=?1;", + -1, &statement, NULL + ); + } + if (rc == SQLITE_OK) { + rc = sqlite3_bind_int64(statement, 1, session_row_id); + } + if (rc == SQLITE_OK) { + rc = sqlite3_step(statement); + } + if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK && + rc == SQLITE_DONE) { + rc = SQLITE_ERROR; + } + statement = NULL; + if (rc != SQLITE_DONE) { + (void)trainlog_database_rollback(database); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + rc = sqlite3_exec( + database->connection, + "CREATE TEMP TABLE trainlog_correction_feedback_revisions(" + "revision_id TEXT PRIMARY KEY,feedback_id TEXT NOT NULL," + "created_at TEXT NOT NULL,raw_text TEXT NOT NULL);" + "INSERT INTO trainlog_correction_feedback_revisions " + "SELECT r.revision_id,r.feedback_id,r.created_at,r.raw_text " + "FROM exercise_feedback_revisions r JOIN trainlog_correction_feedback f " + "ON f.feedback_id=r.feedback_id;", + NULL, NULL, NULL + ); + if (rc != SQLITE_OK) { + (void)trainlog_database_rollback(database); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + rc = sqlite3_prepare_v2( database->connection, "DELETE FROM session_exercises " @@ -3770,6 +3948,47 @@ TrainlogStatus trainlog_database_replace_session_exercises( return status; } + rc = sqlite3_prepare_v2( + database->connection, + "INSERT INTO exercise_feedback" + "(feedback_id,session_exercise_row_id,observed_at,raw_text) " + "SELECT saved.feedback_id,se.id,saved.observed_at,saved.raw_text " + "FROM trainlog_correction_feedback AS saved " + "JOIN session_exercises AS se ON se.entry_id=saved.entry_id " + "WHERE se.session_row_id=?1;", + -1, &statement, NULL + ); + if (rc == SQLITE_OK) { + rc = sqlite3_bind_int64(statement, 1, session_row_id); + } + if (rc == SQLITE_OK) { + rc = sqlite3_step(statement); + } + if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK && + rc == SQLITE_DONE) { + rc = SQLITE_ERROR; + } + statement = NULL; + if (rc == SQLITE_DONE) { + rc = sqlite3_exec( + database->connection, + "INSERT INTO exercise_feedback_revisions(revision_id,feedback_id,created_at,raw_text) " + "SELECT r.revision_id,r.feedback_id,r.created_at,r.raw_text " + "FROM trainlog_correction_feedback_revisions r " + "JOIN exercise_feedback f ON f.feedback_id=r.feedback_id;", + NULL, NULL, NULL + ); + } + if (rc != SQLITE_OK || sqlite3_exec( + database->connection, + "DROP TABLE trainlog_correction_feedback_revisions;" + "DROP TABLE trainlog_correction_feedback;", + NULL, NULL, NULL + ) != SQLITE_OK) { + (void)trainlog_database_rollback(database); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + status = trainlog_database_commit(database); if (status != TRAINLOG_STATUS_OK) { diff --git a/tui/src/sync.c b/tui/src/sync.c index 99a6254..7bc7cf4 100644 --- a/tui/src/sync.c +++ b/tui/src/sync.c @@ -57,6 +57,10 @@ static const char *const EXERCISE_BODY_ZONES_NAME = static const char *const EXERCISE_ALIASES_NAME = "trainlog-exercise-aliases-v1.json"; +static const char *const TRAINING_FEEDBACK_NAME = + "trainlog-training-feedback-v2.json"; +static const char *const TRAINING_FEEDBACK_V1_NAME = + "trainlog-training-feedback-v1.json"; static const char *const SYNC_REQUEST_NAME = "trainlog-sync-request-v1.json"; @@ -90,12 +94,16 @@ static const char *const EXERCISE_BODY_ZONES_LOCAL = static const char *const EXERCISE_ALIASES_LOCAL = "/tmp/trainlog-exercise-aliases-v1.json"; +static const char *const TRAINING_FEEDBACK_LOCAL = + "/tmp/trainlog-training-feedback-v2.json"; static const char *const EXERCISE_BODY_ZONES_RESULT = "/tmp/trainlog-exercise-body-zones-result.txt"; static const char *const EXERCISE_ALIASES_RESULT = "/tmp/trainlog-exercise-aliases-result.txt"; +static const char *const TRAINING_FEEDBACK_RESULT = + "/tmp/trainlog-training-feedback-result.txt"; static const char *const SYNC_REQUEST_LOCAL = "/tmp/trainlog-sync-request-v1.json"; @@ -3094,6 +3102,36 @@ TrainlogStatus trainlog_sync_run( goto finalize; } + /* CONTRACT: feedback references are resolved only after aliases, + * catalogue/session snapshots, and occurrence equipment reconciliation. */ + status = sync_receive_current_android_artifact(&device, folder_id, + TRAINING_FEEDBACK_NAME, TRAINING_FEEDBACK_LOCAL, &ignored_size); + if (status == TRAINLOG_STATUS_NOT_FOUND) { + /* V2 is authoritative, but a deployed V1-only peer remains readable. + * The importer turns its immutable raw_text into fr0_ and + * cannot erase locally known revisions. */ + status = sync_receive_current_android_artifact(&device, folder_id, + TRAINING_FEEDBACK_V1_NAME, TRAINING_FEEDBACK_LOCAL, &ignored_size); + } + if (status == TRAINLOG_STATUS_OK) { + status = sync_run_python_tool("import_training_feedback.py", + TRAINING_FEEDBACK_LOCAL, database_path, NULL, TRAINING_FEEDBACK_RESULT, + tool_output, sizeof(tool_output)); + if (status != TRAINLOG_STATUS_OK || strstr(tool_output, "TRAINING_FEEDBACK_IMPORT=PASS") == NULL) { + char useful[TRAINLOG_SYNC_ERROR_MAX + 1U]; + sync_last_nonempty_line(tool_output, useful, sizeof(useful)); + sync_compose_diagnostic(output->error, sizeof(output->error), + "Android→PC : ressentis : ", useful[0] ? useful : "import échoué"); + final_status = TRAINLOG_STATUS_DATABASE_ERROR; + goto finalize; + } + } else if (status != TRAINLOG_STATUS_NOT_FOUND) { + (void)snprintf(output->error, sizeof(output->error), + "Android→PC : lecture ressentis échouée."); + final_status = status; + goto finalize; + } + if (direction == TRAINLOG_SYNC_ANDROID_TO_PC) { output->success = true; final_status = TRAINLOG_STATUS_OK; @@ -3120,6 +3158,24 @@ outbound: goto finalize; } + status = sync_run_python_tool("export_training_feedback.py", + TRAINING_FEEDBACK_LOCAL, database_path, NULL, TRAINING_FEEDBACK_RESULT, + tool_output, sizeof(tool_output)); + if (status != TRAINLOG_STATUS_OK || strstr(tool_output, "TRAINING_FEEDBACK_EXPORT=PASS") == NULL) { + (void)snprintf(output->error, sizeof(output->error), + "PC→Android : export ressentis échoué."); + final_status = TRAINLOG_STATUS_SYSTEM_ERROR; + goto finalize; + } + status = sync_publish_named(&device, folder_id, TRAINING_FEEDBACK_LOCAL, + TRAINING_FEEDBACK_NAME); + if (status != TRAINLOG_STATUS_OK) { + (void)snprintf(output->error, sizeof(output->error), + "PC→Android : publication ressentis échouée."); + final_status = status; + goto finalize; + } + status = sync_run_python_tool("export_equipment_definitions.py", PC_EQUIPMENT_DEFINITIONS_LOCAL, database_path, diff --git a/tui/src/timeutil.c b/tui/src/timeutil.c index 29a5cd7..4f9d19c 100644 --- a/tui/src/timeutil.c +++ b/tui/src/timeutil.c @@ -8,6 +8,9 @@ #include #include #include +#include + +#include "timestamp.h" TrainlogStatus trainlog_time_now_rfc3339( char *output, @@ -74,3 +77,30 @@ TrainlogStatus trainlog_time_now_rfc3339( return TRAINLOG_STATUS_OK; } + +TrainlogStatus trainlog_feedback_relative_label( + const char *ended_at, const char *observed_at, bool exercise_feedback, + char *output, size_t output_size) +{ + TrainlogTimestampKey ended, observed; + int written; + int64_t elapsed; + const char *neutral; + if (observed_at == NULL || output == NULL || output_size == 0U) + return TRAINLOG_STATUS_INVALID_ARGUMENT; + neutral = exercise_feedback ? "Ressenti" : "H+?"; + if (ended_at == NULL || ended_at[0] == '\0' || + !trainlog_timestamp_parse(ended_at, strlen(ended_at), &ended) || + !trainlog_timestamp_parse(observed_at, strlen(observed_at), &observed)) { + written = snprintf(output, output_size, "%s", neutral); + } else if (observed.utc_second < ended.utc_second) { + written = snprintf(output, output_size, "%s", + exercise_feedback ? "Pendant la séance" : "H+?"); + } else { + elapsed = observed.utc_second - ended.utc_second; + written = snprintf(output, output_size, "H+%lld", + (long long)(elapsed / INT64_C(3600))); + } + return written >= 0 && (size_t)written < output_size + ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_INVALID_ARGUMENT; +} diff --git a/tui/src/tui.c b/tui/src/tui.c index cf678ea..5f75b55 100644 --- a/tui/src/tui.c +++ b/tui/src/tui.c @@ -2356,6 +2356,7 @@ typedef enum TrainlogSessionPhase { TRAINLOG_SESSION_GENERATOR_WARNING, TRAINLOG_SESSION_GENERATOR_PREVIEW, TRAINLOG_SESSION_CONFIRM_REMOVE, + TRAINLOG_SESSION_CONFIRM_SET_REMOVE, TRAINLOG_SESSION_CONFIRM_ABANDON, TRAINLOG_SESSION_CONFIRM_LEAVE, TRAINLOG_SESSION_MESSAGE @@ -2393,6 +2394,7 @@ typedef struct TrainlogSessionController { bool generation_warning_acknowledged; bool correcting; bool replacing_occurrence; + bool destructive_confirm_selected; TrainlogSessionDraftExercise drafts[MAX_SESSION_EXERCISES]; size_t draft_count; size_t selected; @@ -2428,6 +2430,19 @@ typedef struct TrainlogSessionController { TrainlogDurabilityState durability; } TrainlogSessionController; +/* WHY: compiler-visible source buffers are wider than the shell route field. + * CONTRACT: stable identities are copied whole or rejected, never truncated. + * INVARIANT: a failed copy leaves an empty, terminated destination. */ +static bool copy_ui_stable_id(char *output, size_t capacity, const char *value) +{ + size_t length; + if (output == NULL || capacity == 0U || value == NULL) return false; + length = strlen(value); + if (length >= capacity) { output[0] = '\0'; return false; } + (void)memcpy(output, value, length + 1U); + return true; +} + typedef enum TrainlogEquipmentPhase { TRAINLOG_EQUIPMENT_IDLE = 0, TRAINLOG_EQUIPMENT_NAME, @@ -2700,6 +2715,10 @@ typedef struct TrainlogAppContext { size_t session_entry_selected; size_t session_set_scroll; bool session_detail_error; + TrainlogFeedbackView *exercise_feedback; + TrainlogFeedbackView *session_followups; + size_t exercise_feedback_count; + size_t session_followup_count; TrainlogBodyObservationRecord body_records[MAX_BODY_OBSERVATIONS]; TrainlogBodyObservationRecord body_detail; TrainlogBodyMetricPoint body_metric_points[MAX_BODY_METRIC_POINTS]; @@ -4445,8 +4464,8 @@ static bool equipment_controller_save(TrainlogAppContext *app) (void)snprintf(controller->message, sizeof(controller->message), "Équipement personnel créé : %.120s", controller->pending.display_name); app_shell_refresh_list(app); - (void)snprintf(app->list.selected_id, sizeof(app->list.selected_id), "%s", - controller->pending.equipment_id); + if (!copy_ui_stable_id(app->list.selected_id, sizeof(app->list.selected_id), + controller->pending.equipment_id)) return false; trainlog_list_set_items(&app->list, app->stable_ids, app->loaded_count, app->list.visible_rows, app->list.total_known, app->list.more_available); return true; @@ -4750,8 +4769,8 @@ static bool exercise_controller_save(TrainlogAppContext *app) } else { app_shell_refresh_list(app); - (void)snprintf(app->list.selected_id, sizeof(app->list.selected_id), - "%s", created.exercise_id); + if (!copy_ui_stable_id(app->list.selected_id, + sizeof(app->list.selected_id), created.exercise_id)) return false; trainlog_list_set_items(&app->list, app->stable_ids, app->loaded_count, app->list.visible_rows, app->list.total_known, app->list.more_available); @@ -4952,6 +4971,12 @@ static void app_shell_release_session_detail(TrainlogAppContext *app) app->session_entry_count); (void)memset(app->session_entries, 0, sizeof(app->session_entries)); app->session_entry_count = 0U; + free(app->exercise_feedback); + free(app->session_followups); + app->exercise_feedback = NULL; + app->session_followups = NULL; + app->exercise_feedback_count = 0U; + app->session_followup_count = 0U; } static void app_shell_open_session_detail(TrainlogAppContext *app, @@ -4961,6 +4986,23 @@ static void app_shell_open_session_detail(TrainlogAppContext *app, app->session_detail_error = trainlog_database_get_session_details(app->database, session_id, &app->session_detail, app->session_entries, MAX_SESSION_EXERCISES, &app->session_entry_count) != TRAINLOG_STATUS_OK; + if (!app->session_detail_error) { + /* Resource bound matches the strict companion cardinality. The cache + * is detail-owned and released on every navigation replacement. */ + app->exercise_feedback = calloc(TRAINLOG_FEEDBACK_VIEW_MAX, + sizeof(*app->exercise_feedback)); + app->session_followups = calloc(TRAINLOG_FEEDBACK_VIEW_MAX, + sizeof(*app->session_followups)); + if (app->exercise_feedback == NULL || app->session_followups == NULL) + app->session_detail_error = true; + } + if (!app->session_detail_error) { + app->session_detail_error = trainlog_database_list_training_feedback( + app->database, session_id, app->exercise_feedback, + TRAINLOG_FEEDBACK_VIEW_MAX, &app->exercise_feedback_count, + app->session_followups, TRAINLOG_FEEDBACK_VIEW_MAX, + &app->session_followup_count) != TRAINLOG_STATUS_OK; + } app->session_entry_selected = 0U; app->session_set_scroll = 0U; (void)trainlog_navigation_open(&app->navigation, TRAINLOG_ROUTE_SESSION_DETAIL, @@ -5559,13 +5601,20 @@ static void app_shell_render_session_controller(TrainlogAppContext *app) } else if (session->phase == TRAINLOG_SESSION_CONFIRM_REMOVE) { trainlog_surface_printf(app->content, row++, 2, "Retirer cette occurrence de la séance ?"); - trainlog_surface_printf(app->content, row, 4, - "1 confirmer · 0/Échap annuler — le catalogue reste intact"); + trainlog_surface_printf(app->content, row, 4, "%s Non %s Oui, supprimer", + session->destructive_confirm_selected ? "[ ]" : "[>]", + session->destructive_confirm_selected ? "[>]" : "[ ]"); + } else if (session->phase == TRAINLOG_SESSION_CONFIRM_SET_REMOVE) { + trainlog_surface_printf(app->content, row++, 2, "Supprimer cette série ?"); + trainlog_surface_printf(app->content, row, 4, "%s Non %s Oui, supprimer", + session->destructive_confirm_selected ? "[ ]" : "[>]", + session->destructive_confirm_selected ? "[>]" : "[ ]"); } else if (session->phase == TRAINLOG_SESSION_CONFIRM_ABANDON) { trainlog_surface_printf(app->content, row++, 2, "Abandonner la séance en cours ?"); - trainlog_surface_printf(app->content, row, 4, - "1 confirmer · 0/Échap annuler"); + trainlog_surface_printf(app->content, row, 4, "%s Non %s Oui, abandonner", + session->destructive_confirm_selected ? "[ ]" : "[>]", + session->destructive_confirm_selected ? "[>]" : "[ ]"); } else if (session->phase == TRAINLOG_SESSION_CONFIRM_LEAVE) { trainlog_surface_printf(app->content, row++, 2, "Proposition non acceptée conservée en mémoire."); @@ -6927,6 +6976,25 @@ static void app_shell_render_content(TrainlogAppContext *app) app->session_entry_count, entry->name); trainlog_surface_printf(app->content, row++, 2, "Occurrence : %s", entry->entry_id); + { + size_t feedback_index; + bool heading = false; + for (feedback_index = 0U; + feedback_index < app->exercise_feedback_count && + row < app->layout.content.height; ++feedback_index) { + TrainlogFeedbackView *feedback = &app->exercise_feedback[feedback_index]; + char label[32]; + if (strcmp(feedback->entry_id, entry->entry_id) != 0) continue; + if (!heading) { + trainlog_surface_printf(app->content, row++, 2, "RESSENTIS EXERCICES"); + heading = true; + } + (void)trainlog_feedback_relative_label(app->session_detail.ended_at, + feedback->observed_at, true, label, sizeof(label)); + trainlog_surface_printf(app->content, row++, 4, "%s %s", label, + feedback->raw_text); + } + } if (entry->equipment_id[0] != '\0') trainlog_surface_printf(app->content, row++, 2, "Équipement : %s (i pour la fiche)", entry->equipment_id); @@ -6970,6 +7038,19 @@ static void app_shell_render_content(TrainlogAppContext *app) } } } + if (app->session_followup_count > 0U && row < app->layout.content.height) { + size_t followup_index; + trainlog_surface_printf(app->content, row++, 2, "SUIVI APRÈS SÉANCE"); + for (followup_index = 0U; followup_index < app->session_followup_count && + row < app->layout.content.height; ++followup_index) { + char label[32]; + (void)trainlog_feedback_relative_label(app->session_detail.ended_at, + app->session_followups[followup_index].observed_at, + false, label, sizeof(label)); + trainlog_surface_printf(app->content, row++, 4, "%s %s", label, + app->session_followups[followup_index].raw_text); + } + } } } else if (route == TRAINLOG_ROUTE_EQUIPMENT_DETAIL) { const TrainlogResolvedEquipment *item = &app->equipment_detail; @@ -7785,9 +7866,15 @@ static void app_shell_dispatch_overlay(TrainlogAppContext *app, int key) controller->merge_target.exercise_id); if (status == TRAINLOG_STATUS_OK) { app->exercise_detail = controller->merge_target; - (void)snprintf(app->navigation.current.stable_id, - sizeof(app->navigation.current.stable_id), "%s", - controller->merge_target.exercise_id); + if (!copy_ui_stable_id(app->navigation.current.stable_id, + sizeof(app->navigation.current.stable_id), + controller->merge_target.exercise_id)) { + controller->phase = TRAINLOG_EXERCISE_MERGE_RESULT; + (void)snprintf(controller->message, + sizeof(controller->message), + "Fusion terminée; identifiant UI hors borne."); + return; + } app_shell_load_exercise_detail_metadata(app); (void)snprintf(controller->message, sizeof(controller->message), "Fusion terminée. Cible sélectionnée : %.112s", @@ -8327,22 +8414,41 @@ static bool app_shell_dispatch_session(TrainlogAppContext *app, int key) return true; } if (session->phase == TRAINLOG_SESSION_CONFIRM_REMOVE) { - if (key == '1' && session->selected < session->draft_count) { + if (key == TRAINLOG_KEY_LEFT || key == TRAINLOG_KEY_RIGHT || key == TRAINLOG_KEY_TAB) + session->destructive_confirm_selected = !session->destructive_confirm_selected; + else if ((key == TRAINLOG_KEY_ENTER || key == '\n') && session->destructive_confirm_selected && session->selected < session->draft_count) { draft_delete_exercise(session->drafts, &session->draft_count, session->selected); if (session->selected >= session->draft_count && session->selected > 0U) --session->selected; session->dirty = true; session->phase = TRAINLOG_SESSION_DRAFT; - } else if (key == '0' || key == TRAINLOG_KEY_ESCAPE || key == 27) + } else if ((key == TRAINLOG_KEY_ENTER || key == '\n') || key == 'q' || key == 'Q' || key == TRAINLOG_KEY_ESCAPE || key == 27) session->phase = TRAINLOG_SESSION_DRAFT; return true; } + if (session->phase == TRAINLOG_SESSION_CONFIRM_SET_REMOVE) { + TrainlogSessionDraftExercise *draft = &session->drafts[session->selected]; + if (key == TRAINLOG_KEY_LEFT || key == TRAINLOG_KEY_RIGHT || key == TRAINLOG_KEY_TAB) + session->destructive_confirm_selected = !session->destructive_confirm_selected; + else if ((key == TRAINLOG_KEY_ENTER || key == '\n') && session->destructive_confirm_selected && draft->input.set_count > 0U) { + for (size_t index = session->set_selected; index + 1U < draft->input.set_count; ++index) + draft->sets[index] = draft->sets[index + 1U]; + --draft->input.set_count; + if (session->set_selected >= draft->input.set_count && session->set_selected > 0U) --session->set_selected; + draft_bind_input(draft); session->dirty = true; session->phase = TRAINLOG_SESSION_ACTUALS; + } else if ((key == TRAINLOG_KEY_ENTER || key == '\n') || key == 'q' || key == 'Q' || key == TRAINLOG_KEY_ESCAPE || key == 27) + session->phase = TRAINLOG_SESSION_ACTUALS; + session_controller_sync_durability(session); + return true; + } if (session->phase == TRAINLOG_SESSION_CONFIRM_ABANDON) { - if (key == '1') { + if (key == TRAINLOG_KEY_LEFT || key == TRAINLOG_KEY_RIGHT || key == TRAINLOG_KEY_TAB) + session->destructive_confirm_selected = !session->destructive_confirm_selected; + else if ((key == TRAINLOG_KEY_ENTER || key == '\n') && session->destructive_confirm_selected) { session_controller_clear_draft(session); app_shell_open_route(app, TRAINLOG_ROUTE_SESSIONS); - } else if (key == '0' || key == TRAINLOG_KEY_ESCAPE || key == 27) + } else if ((key == TRAINLOG_KEY_ENTER || key == '\n') || key == 'q' || key == 'Q' || key == TRAINLOG_KEY_ESCAPE || key == 27) session->phase = TRAINLOG_SESSION_DRAFT; return true; } @@ -8530,13 +8636,8 @@ static bool app_shell_dispatch_session(TrainlogAppContext *app, int key) session->set_field = session->set_field == 0U ? 1U : 0U; else if ((key == 'd' || key == 'D' || key == TRAINLOG_KEY_DELETE) && draft->input.set_count > 0U) { - for (size_t index = session->set_selected; - index + 1U < draft->input.set_count; ++index) - draft->sets[index] = draft->sets[index + 1U]; - --draft->input.set_count; - if (session->set_selected >= draft->input.set_count && - session->set_selected > 0U) --session->set_selected; - draft_bind_input(draft); session->dirty = true; + session->destructive_confirm_selected = false; + session->phase = TRAINLOG_SESSION_CONFIRM_SET_REMOVE; } else if ((key == 'a' || key == 'A') && draft->input.set_count < MAX_SETS_PER_EXERCISE) { (void)memset(&session->pending_set, 0, sizeof(session->pending_set)); @@ -8576,8 +8677,10 @@ static bool app_shell_dispatch_session(TrainlogAppContext *app, int key) session->planning_field = 0U; session->phase = TRAINLOG_SESSION_PLANNING; } else if ((key == 'd' || key == 'D' || key == TRAINLOG_KEY_DELETE) && - session->draft_count > 0U) + session->draft_count > 0U) { + session->destructive_confirm_selected = false; session->phase = TRAINLOG_SESSION_CONFIRM_REMOVE; + } else if ((key == TRAINLOG_KEY_ENTER || key == '\n' || key == 'e' || key == 'E') && session->draft_count > 0U) { session->set_selected = 0U; @@ -8592,8 +8695,10 @@ static bool app_shell_dispatch_session(TrainlogAppContext *app, int key) if (status != TRAINLOG_STATUS_INVALID_ARGUMENT) (void)snprintf(session->message, sizeof(session->message), "Échec de l’enregistrement; le brouillon est conservé."); - } else if (key == 'q' || key == 'Q') + } else if (key == 'q' || key == 'Q') { + session->destructive_confirm_selected = false; session->phase = TRAINLOG_SESSION_CONFIRM_ABANDON; + } else if (key == TRAINLOG_KEY_ESCAPE || key == 27) app_shell_open_route(app, TRAINLOG_ROUTE_SESSIONS); session_controller_sync_durability(session); diff --git a/tui/tests/test_body_zones.c b/tui/tests/test_body_zones.c index 59e1f23..d213a62 100644 --- a/tui/tests/test_body_zones.c +++ b/tui/tests/test_body_zones.c @@ -195,7 +195,7 @@ static int migration_preserves_identity_and_history(void) CHECK(sqlite3_close(raw) == SQLITE_OK); raw = NULL; CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK); - CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK && version == 13); + CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK && version == 15); CHECK(trainlog_database_list_exercise_body_zones(database, "ex_b432623f-bfe9-4daf-a653-60ec7fdffbde", relations, 4U, &count) == TRAINLOG_STATUS_OK); CHECK(count == 2U); diff --git a/tui/tests/test_feedback_correction.c b/tui/tests/test_feedback_correction.c new file mode 100644 index 0000000..65b5c68 --- /dev/null +++ b/tui/tests/test_feedback_correction.c @@ -0,0 +1,137 @@ +/** @file test_feedback_correction.c + * @brief Stable-entry feedback preservation across completed-session edits. */ +#include +#include +#include +#include +#include "trainlog/database.h" +#include "../src/database_internal.h" + +#define CHECK(c) do { if (!(c)) { (void)fprintf(stderr, \ + "CHECK failed at %s:%d: %s\n", __FILE__, __LINE__, #c); return false; \ +} } while (0) + +static bool exec_sql(TrainlogDatabase *db, const char *sql) +{ return sqlite3_exec(db->connection, sql, NULL, NULL, NULL) == SQLITE_OK; } + +static sqlite3_int64 scalar(TrainlogDatabase *db, const char *sql) +{ + sqlite3_stmt *stmt = NULL; + sqlite3_int64 value = -1; + if (sqlite3_prepare_v2(db->connection, sql, -1, &stmt, NULL) == SQLITE_OK && + sqlite3_step(stmt) == SQLITE_ROW) value = sqlite3_column_int64(stmt, 0); + (void)sqlite3_finalize(stmt); + return value; +} + +static void bind_entry(TrainlogSessionExerciseInput *entry, const char *entry_id, + const char *exercise_id, TrainlogSetInput *set, int reps) +{ + (void)memset(entry, 0, sizeof(*entry)); + (void)memset(set, 0, sizeof(*set)); + (void)snprintf(entry->entry_id, sizeof(entry->entry_id), "%s", entry_id); + (void)snprintf(entry->exercise_id, sizeof(entry->exercise_id), "%s", exercise_id); + entry->recording_mode = TRAINLOG_RECORDING_SETS; + entry->sets = set; + entry->set_count = 1U; + set->reps = reps; +} + +static bool matches(const TrainlogFeedbackView *item, const char *id, + const char *entry, const char *at, const char *text) +{ + return strcmp(item->stable_id, id) == 0 && strcmp(item->entry_id, entry) == 0 && + strcmp(item->observed_at, at) == 0 && strcmp(item->raw_text, text) == 0; +} + +static bool test_correction(void) +{ + TrainlogDatabase *db = NULL; + TrainlogSessionInput session; + TrainlogSessionExerciseInput original[2], replacement[2]; + TrainlogSetInput original_sets[2], replacement_sets[2]; + TrainlogFeedbackView feedback[8], followups[4]; + size_t feedback_count = 0U, followup_count = 0U; + sqlite3_int64 old_a, old_b, new_a, new_b; + + CHECK(trainlog_database_open(":memory:", &db) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_insert_exercise(db, "ex_press", "Presse", "presse", + TRAINLOG_TRACKING_REPS) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_insert_exercise(db, "ex_plank", "Gainage", "gainage", + TRAINLOG_TRACKING_REPS) == TRAINLOG_STATUS_OK); + bind_entry(&original[0], "sxe_a", "ex_press", &original_sets[0], 10); + bind_entry(&original[1], "sxe_b", "ex_plank", &original_sets[1], 20); + (void)memset(&session, 0, sizeof(session)); + (void)snprintf(session.session_id, sizeof(session.session_id), "%s", "se_feedback_edit"); + (void)snprintf(session.started_at, sizeof(session.started_at), "%s", + "2026-09-01T10:00:00+02:00"); + (void)snprintf(session.ended_at, sizeof(session.ended_at), "%s", + "2026-09-01T11:00:00+02:00"); + session.session_type = TRAINLOG_SESSION_TRAINING; + session.exercises = original; + session.exercise_count = 2U; + CHECK(trainlog_database_insert_session(db, &session) == TRAINLOG_STATUS_OK); + CHECK(exec_sql(db, + "INSERT INTO exercise_feedback(feedback_id,session_exercise_row_id,observed_at,raw_text) SELECT 'fb_a1',id,'2026-09-01T10:20:00+02:00','A première' FROM session_exercises WHERE entry_id='sxe_a';" + "INSERT INTO exercise_feedback(feedback_id,session_exercise_row_id,observed_at,raw_text) SELECT 'fb_a2',id,'2026-09-01T10:30:00+02:00','A deuxième' FROM session_exercises WHERE entry_id='sxe_a';" + "INSERT INTO exercise_feedback(feedback_id,session_exercise_row_id,observed_at,raw_text) SELECT 'fb_b1',id,'2026-09-01T10:40:00+02:00','B unique' FROM session_exercises WHERE entry_id='sxe_b';" + "INSERT INTO session_followups(followup_id,session_row_id,observed_at,raw_text) SELECT 'fu_1',id,'2026-09-01T19:00:00+02:00','suivi inchangé' FROM sessions WHERE session_id='se_feedback_edit';")); + /* Reproduce the pre-fix primitive: deleting reconstructed parents really + * does cascade all feedback. Roll back, then exercise the guarded API. */ + CHECK(exec_sql(db, "BEGIN; DELETE FROM session_exercises WHERE session_row_id=(SELECT id FROM sessions WHERE session_id='se_feedback_edit');")); + CHECK(scalar(db, "SELECT COUNT(*) FROM exercise_feedback;") == 0); + CHECK(exec_sql(db, "ROLLBACK;")); + CHECK(scalar(db, "SELECT COUNT(*) FROM exercise_feedback;") == 3); + old_a = scalar(db, "SELECT id FROM session_exercises WHERE entry_id='sxe_a';"); + old_b = scalar(db, "SELECT id FROM session_exercises WHERE entry_id='sxe_b';"); + + /* Reorder and change A's exercise while retaining both logical IDs. */ + bind_entry(&replacement[0], "sxe_b", "ex_plank", &replacement_sets[0], 21); + bind_entry(&replacement[1], "sxe_a", "ex_plank", &replacement_sets[1], 11); + CHECK(trainlog_database_replace_session_exercises(db, "se_feedback_edit", + replacement, 2U) == TRAINLOG_STATUS_OK); + new_a = scalar(db, "SELECT id FROM session_exercises WHERE entry_id='sxe_a';"); + new_b = scalar(db, "SELECT id FROM session_exercises WHERE entry_id='sxe_b';"); + CHECK(old_a != new_a && old_b != new_b); + CHECK(scalar(db, "SELECT COUNT(*) FROM exercise_feedback f JOIN session_exercises se ON se.id=f.session_exercise_row_id WHERE se.entry_id='sxe_a' AND se.exercise_row_id=(SELECT id FROM exercises WHERE exercise_id='ex_plank');") == 2); + CHECK(trainlog_database_list_training_feedback(db, "se_feedback_edit", feedback, + 8U, &feedback_count, followups, 4U, &followup_count) == TRAINLOG_STATUS_OK); + CHECK(feedback_count == 3U && followup_count == 1U); + CHECK(matches(&feedback[0], "fb_a1", "sxe_a", "2026-09-01T10:20:00+02:00", "A première")); + CHECK(matches(&feedback[1], "fb_a2", "sxe_a", "2026-09-01T10:30:00+02:00", "A deuxième")); + CHECK(matches(&feedback[2], "fb_b1", "sxe_b", "2026-09-01T10:40:00+02:00", "B unique")); + CHECK(strcmp(followups[0].stable_id, "fu_1") == 0 && + strcmp(followups[0].raw_text, "suivi inchangé") == 0); + + /* Remove A and add C: B survives, A cascades, C inherits nothing. */ + bind_entry(&replacement[0], "sxe_b", "ex_plank", &replacement_sets[0], 22); + bind_entry(&replacement[1], "sxe_c", "ex_press", &replacement_sets[1], 12); + CHECK(trainlog_database_replace_session_exercises(db, "se_feedback_edit", + replacement, 2U) == TRAINLOG_STATUS_OK); + CHECK(scalar(db, "SELECT COUNT(*) FROM exercise_feedback;") == 1); + CHECK(scalar(db, "SELECT COUNT(*) FROM exercise_feedback f JOIN session_exercises se ON se.id=f.session_exercise_row_id WHERE se.entry_id='sxe_b';") == 1); + CHECK(scalar(db, "SELECT COUNT(*) FROM exercise_feedback f JOIN session_exercises se ON se.id=f.session_exercise_row_id WHERE se.entry_id='sxe_c';") == 0); + CHECK(scalar(db, "SELECT COUNT(*) FROM session_followups;") == 1); + + /* Inject reattachment failure; rollback restores parent row, work and histories. */ + new_b = scalar(db, "SELECT id FROM session_exercises WHERE entry_id='sxe_b';"); + CHECK(exec_sql(db, "CREATE TRIGGER fail_correction_feedback BEFORE INSERT ON exercise_feedback BEGIN SELECT RAISE(ABORT,'synthetic correction failure'); END;")); + bind_entry(&replacement[0], "sxe_b", "ex_press", &replacement_sets[0], 99); + CHECK(trainlog_database_replace_session_exercises(db, "se_feedback_edit", + replacement, 1U) == TRAINLOG_STATUS_DATABASE_ERROR); + CHECK(scalar(db, "SELECT id FROM session_exercises WHERE entry_id='sxe_b';") == new_b); + CHECK(scalar(db, "SELECT reps FROM performed_sets WHERE session_exercise_row_id=(SELECT id FROM session_exercises WHERE entry_id='sxe_b');") == 22); + CHECK(scalar(db, "SELECT COUNT(*) FROM exercise_feedback WHERE feedback_id='fb_b1';") == 1); + CHECK(scalar(db, "SELECT COUNT(*) FROM session_followups WHERE followup_id='fu_1';") == 1); + CHECK(exec_sql(db, "DROP TRIGGER fail_correction_feedback;")); + CHECK(scalar(db, "SELECT COUNT(*) FROM exercise_feedback f LEFT JOIN session_exercises se ON se.id=f.session_exercise_row_id WHERE se.id IS NULL;") == 0); + trainlog_database_close(db); + return true; +} + +int main(void) +{ + if (!test_correction()) return 1; + (void)puts("Completed-session feedback correction tests passed."); + return 0; +} diff --git a/tui/tests/test_feedback_labels.c b/tui/tests/test_feedback_labels.c new file mode 100644 index 0000000..561dd96 --- /dev/null +++ b/tui/tests/test_feedback_labels.c @@ -0,0 +1,12 @@ +#include "trainlog/timeutil.h" +#include +#include +#define CHECK(x) do { if (!(x)) { fprintf(stderr,"FAIL %d\n",__LINE__); return 1; } } while (0) +static int label(const char *end,const char *at,bool exercise,const char *expected){char out[64];CHECK(trainlog_feedback_relative_label(end,at,exercise,out,sizeof(out))==TRAINLOG_STATUS_OK);CHECK(strcmp(out,expected)==0);CHECK(strstr(out,"H+-")==NULL);return 0;} +int main(void){const char *end="2026-09-01T10:00:00+02:00"; + CHECK(label(end,"2026-09-01T09:20:00+02:00",true,"Pendant la séance")==0); + CHECK(label(end,"2026-09-01T09:59:59+02:00",true,"Pendant la séance")==0); + CHECK(label(end,end,true,"H+0")==0);CHECK(label(end,"2026-09-01T18:00:00+02:00",true,"H+8")==0); + CHECK(label(NULL,"2026-09-01T09:20:00+02:00",true,"Ressenti")==0); + CHECK(label(NULL,"2026-09-01T18:00:00+02:00",false,"H+?")==0); + CHECK(label(end,"2026-09-01T09:00:00+02:00",false,"H+?")==0);return 0;} diff --git a/tui/tests/test_schema_v7_migration.c b/tui/tests/test_schema_v7_migration.c index 82ea801..b18ba91 100644 --- a/tui/tests/test_schema_v7_migration.c +++ b/tui/tests/test_schema_v7_migration.c @@ -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 v13: SQLite rc=", - strlen("migrate database to schema v13: SQLite rc=")) == 0); + CHECK(strncmp(diagnostic, "migrate database to schema v15: SQLite rc=", + strlen("migrate database to schema v15: SQLite rc=")) == 0); CHECK(strstr(diagnostic, "extended_rc=") != NULL); CHECK(strstr(diagnostic, "custom_equipment") != NULL); CHECK(strstr(diagnostic, "already exists") != NULL); @@ -145,7 +145,7 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void) static bool test_newer_schema_has_application_diagnostic(void) { - char path[] = "/tmp/trainlog-schema-v14-XXXXXX"; + char path[] = "/tmp/trainlog-schema-v16-XXXXXX"; char diagnostic[256]; sqlite3 *raw = NULL; TrainlogDatabase *database = 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=14;", NULL, NULL, NULL) == SQLITE_OK); + CHECK(sqlite3_exec(raw, "PRAGMA user_version=16;", 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 14 is newer") != NULL); + CHECK(strstr(diagnostic, "schema version 16 is newer") != NULL); CHECK(strstr(diagnostic, "SQLite") == NULL); CHECK(unlink(path) == 0); return true; diff --git a/tui/tests/test_schema_v9_migration.c b/tui/tests/test_schema_v9_migration.c index ab202c1..847cced 100644 --- a/tui/tests/test_schema_v9_migration.c +++ b/tui/tests/test_schema_v9_migration.c @@ -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 v13") != NULL); + CHECK(strstr(diagnostic, "migrate database to schema v15") != NULL); CHECK(strstr(diagnostic, "performed_sets_v9") != NULL); CHECK(sqlite3_open(path, &raw) == SQLITE_OK); diff --git a/tui/tests/test_tui_workflows.c b/tui/tests/test_tui_workflows.c index c5b8bb8..26224b0 100644 --- a/tui/tests/test_tui_workflows.c +++ b/tui/tests/test_tui_workflows.c @@ -370,6 +370,35 @@ static bool test_shell_generator_keep_and_accept_has_no_actuals(void) return true; } +static bool test_destructive_session_choices_default_to_cancel(void) +{ + TrainlogAppContext app; + (void)memset(&app, 0, sizeof(app)); + app.navigation.current.route = TRAINLOG_ROUTE_SESSION_CURRENT; + app.session.has_draft = true; + app.session.draft_count = 1U; + app.session.phase = TRAINLOG_SESSION_ACTUALS; + app.session.drafts[0].input.set_count = 2U; + app.session.drafts[0].sets[0].reps = 8; + app.session.drafts[0].sets[1].reps = 7; + draft_bind_input(&app.session.drafts[0]); + CHECK(app_shell_dispatch_session(&app, 'd')); + CHECK(app.session.phase == TRAINLOG_SESSION_CONFIRM_SET_REMOVE); + CHECK(!app.session.destructive_confirm_selected); + CHECK(app_shell_dispatch_session(&app, TRAINLOG_KEY_ENTER)); + CHECK(app.session.phase == TRAINLOG_SESSION_ACTUALS); + CHECK(app.session.drafts[0].input.set_count == 2U); + CHECK(app_shell_dispatch_session(&app, 'd')); + CHECK(app_shell_dispatch_session(&app, TRAINLOG_KEY_RIGHT)); + CHECK(app_shell_dispatch_session(&app, TRAINLOG_KEY_ENTER)); + CHECK(app.session.drafts[0].input.set_count == 1U); + app.session.phase = TRAINLOG_SESSION_DRAFT; + CHECK(app_shell_dispatch_session(&app, 'd')); + CHECK(app_shell_dispatch_session(&app, 'q')); + CHECK(app.session.draft_count == 1U); + return true; +} + static bool test_shell_session_forms_keep_plan_and_actuals_separate(void) { TrainlogDatabase *database = NULL; @@ -2206,6 +2235,7 @@ int main(void) if (!test_knowledge_scrolls_long_lists_at_minimum_terminal() || !test_shell_session_navigation_preserves_run_draft() || !test_shell_generator_keep_and_accept_has_no_actuals() || + !test_destructive_session_choices_default_to_cancel() || !test_shell_session_forms_keep_plan_and_actuals_separate() || !test_shell_continuous_supplemental_fields_have_no_fake_set() || !test_shell_generator_dose_edit_drops_stale_load_provenance() ||