From 3ded6ee425c086d49a15928bd0f35898d852d8af Mon Sep 17 00:00:00 2001 From: fy59 Date: Wed, 23 Sep 2026 12:34:01 +0200 Subject: [PATCH] feat(cardio): add cardio session type --- CHANGELOG.md | 8 + README.md | 2 +- .../trainlog/data/AndroidBackupService.kt | 4 +- .../trainlog/data/SyncGenerationService.kt | 2 + .../trainlog/data/TrainlogRepository.kt | 96 +++++++-- .../trainlog/model/SessionModels.kt | 9 +- .../labfytools/trainlog/ui/SessionScreen.kt | 41 ++-- .../app/src/main/res/values-en/strings.xml | 1 + android/app/src/main/res/values/strings.xml | 1 + .../data/AndroidV10PlanningMigrationTest.kt | 2 +- .../AndroidV11ExerciseAliasMigrationTest.kt | 2 +- .../AndroidV16AiSessionDraftMigrationTest.kt | 2 +- ...AndroidV25ProgramExecutionMigrationTest.kt | 2 +- .../data/AndroidV26SleepMigrationTest.kt | 2 +- .../data/AndroidV27HeartRateMigrationTest.kt | 2 +- .../AndroidV28SessionTimelineMigrationTest.kt | 2 +- .../AndroidV29CardioSessionMigrationTest.kt | 108 ++++++++++ .../labfytools/trainlog/data/BodyZonesTest.kt | 2 +- .../data/BridgeBackupMigrationTest.kt | 2 +- .../data/CardioSessionSyncGenerationTest.kt | 194 ++++++++++++++++++ .../trainlog/data/CardioSessionTypeTest.kt | 95 +++++++++ .../data/SessionPreparationExchangeTest.kt | 2 +- .../trainlog/data/StatisticsRepositoryTest.kt | 2 +- .../data/SyncGenerationServiceTest.kt | 2 +- .../data/TrainlogRepositoryDraftTest.kt | 4 +- docs/current_state.md | 7 +- docs/database.md | 22 ++ docs/roadmap.md | 2 +- docs/sync_exchange.md | 24 ++- docs/tests.md | 28 +++ tests/test_cardio_session_exchange.py | 128 ++++++++++++ tests/test_sync_deployment_tools.py | 5 +- tools/cardio_session_exchange.py | 126 ++++++++++++ tools/causal_delete_exchange.py | 4 +- tools/execution_draft_exchange.py | 4 +- tools/export_ai_history.py | 4 +- tools/export_ai_session_drafts.py | 4 +- tools/export_equipment_associations.py | 4 +- tools/export_equipment_definitions.py | 4 +- tools/export_exercise_aliases.py | 4 +- tools/export_exercise_body_zones.py | 4 +- tools/export_exercise_profile_state.py | 2 +- tools/export_pc_catalog.py | 2 +- tools/export_pc_mobile.py | 6 +- tools/export_programs.py | 4 +- tools/export_session_preparations.py | 4 +- tools/export_training_feedback.py | 4 +- tools/heart_rate_exchange.py | 4 +- tools/import_ai_session_draft.py | 4 +- tools/import_equipment_associations.py | 4 +- tools/import_equipment_definitions.py | 4 +- tools/import_exercise_aliases.py | 4 +- tools/import_exercise_body_zones.py | 4 +- tools/import_exercise_profile_state.py | 4 +- tools/import_mobile_export.py | 8 +- tools/import_training_feedback.py | 4 +- tools/package_sync_candidate.py | 8 +- tools/program_execution_exchange.py | 4 +- tools/session_timeline_exchange.py | 4 +- tools/sleep_diary_exchange.py | 4 +- tools/sync_generation_exchange.py | 18 +- tui/include/trainlog/database.h | 2 +- tui/include/trainlog/model.h | 3 +- tui/meson.build | 13 ++ tui/src/database.c | 92 +++++++-- tui/tests/test_schema_v29_migration.c | 4 +- tui/tests/test_schema_v30_migration.c | 2 +- tui/tests/test_schema_v31_migration.c | 2 +- tui/tests/test_schema_v32_migration.c | 73 +++++++ tui/tests/test_schema_v7_migration.c | 8 +- tui/tests/test_schema_v9_migration.c | 2 +- 71 files changed, 1115 insertions(+), 149 deletions(-) create mode 100644 android/app/src/test/java/com/labfytools/trainlog/data/AndroidV29CardioSessionMigrationTest.kt create mode 100644 android/app/src/test/java/com/labfytools/trainlog/data/CardioSessionSyncGenerationTest.kt create mode 100644 android/app/src/test/java/com/labfytools/trainlog/data/CardioSessionTypeTest.kt create mode 100644 tests/test_cardio_session_exchange.py create mode 100644 tools/cardio_session_exchange.py create mode 100644 tui/tests/test_schema_v32_migration.c diff --git a/CHANGELOG.md b/CHANGELOG.md index 43d13a6..9d49883 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,14 @@ causal compensating revision, while Corriger la nuit retains the complete editor. Heart-rate acquisition links directly to the stable sleep `entry_id` and closes at Levé; no return-to-sleep or sleep-stage inference is added. +- Added the true `cardio` session type without widening frozen session + codecs. Android schema v29 and desktop schema v32 introduce canonical + `session_kind=training|max_test|cardio` while the historical + `session_type` compatibility column remains `training|max_test`. + Completed cardio sessions are excluded from `history-v4` and move through + the separate optional `trainlog-cardio-sessions` V1 generation companion; + exact replay is idempotent and identity collision with non-cardio history is + rejected. Development opened after stable v0.1.4 release. diff --git a/README.md b/README.md index f8e7256..3fcb649 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Programs. Sessions → Programmes remains the technical administration/import, list, detail, archive, and delete surface. Web Exercises administration, the factual Dashboard/Analyse read model, and the synchronized Sleep Diary workspace are the stable v0.1.4 baseline. Version -0.1.5 development now has Android/desktop heart-rate persistence and sync plus validated persistent CYCPLUS acquisition and the global `♥ BPM` indicator; training exercise timestamps are the current implementation cursor. +0.1.5 development now has validated persistent CYCPLUS acquisition, the global `♥ BPM` indicator, training exercise timestamps, one-tap Android Sleep capture, and the dedicated synchronized `cardio` session type; Calibration cardio is the current implementation cursor. ## Releases diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/AndroidBackupService.kt b/android/app/src/main/java/com/labfytools/trainlog/data/AndroidBackupService.kt index 03324d3..75d915f 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/AndroidBackupService.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/AndroidBackupService.kt @@ -225,7 +225,7 @@ internal class AndroidBackupService( manifest.getInt("version") == VERSION && manifest.getString("package") == context.packageName && manifest.getBoolean("plaintext") && - manifest.getInt("schema") in 17..28 + manifest.getInt("schema") in 17..29 ) { "Unsupported backup." } @@ -319,7 +319,7 @@ internal class AndroidBackupService( } ) require(db.rawQuery("PRAGMA foreign_key_check", null).use { !it.moveToFirst() }) - require(db.version in 17..28) { "Unsupported Android schema ${db.version}." } + require(db.version in 17..29) { "Unsupported Android schema ${db.version}." } return db.version } finally { db.close() diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationService.kt b/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationService.kt index f60025e..c3e293f 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationService.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationService.kt @@ -52,6 +52,8 @@ internal class SyncGenerationService(private val repository: TrainlogRepository) linkedMapOf( "catalog" to Kind("trainlog-pc-catalog", 1, "catalog-v1.json"), "history" to Kind("trainlog-mobile-export", 4, "history-v4.json"), + "cardio-sessions" to + Kind("trainlog-cardio-sessions", 1, "cardio-sessions-v1.json", false), "execution-drafts" to Kind("trainlog-execution-drafts", 1, "execution-drafts-v1.json"), "exercise-aliases" to 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 53c021a..15b1dac 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 @@ -530,6 +530,9 @@ class TrainlogRepository( data class DatabaseError(val message: String) : SessionExerciseTimingResult } + private fun legacySessionTypeWire(type: SessionType): String = + if (type == SessionType.MAX_TEST) SessionType.MAX_TEST.wireValue else SessionType.TRAINING.wireValue + private fun sleepId(prefix: String): String = "${prefix}_${UUID.randomUUID()}" private fun sleepIdentity(value: String, prefix: String): Boolean = @@ -1485,7 +1488,7 @@ class TrainlogRepository( fun activeSessionTimelineContext(): ActiveSessionTimelineContext? { val db = database.readableDatabase return db.rawQuery( - "SELECT d.session_id,d.started_at,d.session_type,t.entry_id " + + "SELECT d.session_id,d.started_at,d.session_kind,t.entry_id " + "FROM active_session_draft d LEFT JOIN session_timeline_exercises t " + "ON t.session_id=d.session_id AND t.active_slot=1 WHERE d.id=1", null, @@ -1742,13 +1745,13 @@ class TrainlogRepository( HeartRateContextKind.SESSION -> db.rawQuery( "SELECT 1 FROM active_session_draft WHERE id=1 AND session_id=? " + - "AND session_type IN('training','max_test') LIMIT 1", + "AND session_kind IN('training','max_test') LIMIT 1", arrayOf(contextId), ).use { it.moveToFirst() } HeartRateContextKind.CARDIO -> db.rawQuery( "SELECT 1 FROM active_session_draft WHERE id=1 AND session_id=? " + - "AND session_type='cardio' LIMIT 1", + "AND session_kind='cardio' LIMIT 1", arrayOf(contextId), ).use { it.moveToFirst() } HeartRateContextKind.SLEEP -> @@ -2206,6 +2209,7 @@ class TrainlogRepository( val artifacts = linkedMapOf("catalog" to buildPcCatalogJson()) afterFirstArtifact?.invoke() artifacts["history"] = buildMobileExport(4, true) + artifacts["cardio-sessions"] = buildCardioSessionsV1Json() artifacts["execution-drafts"] = buildExecutionDraftExportV1Json() artifacts["exercise-aliases"] = buildExerciseAliasesJson() artifacts["exercise-profile-state"] = buildExerciseProfileStateJson() @@ -2379,7 +2383,7 @@ class TrainlogRepository( data class FrequencyCount(var all: Int = 0, var maxima: Int = 0) val frequency = sortedMapOf() val sessionTimes = mutableListOf>() - query("SELECT started_at,session_type FROM sessions AS s WHERE $ACTUAL_SESSION_PREDICATE") { c -> + query("SELECT started_at,session_kind FROM sessions AS s WHERE $ACTUAL_SESSION_PREDICATE") { c -> val time = timestamp(c.getString(0)) ?: return@query sessionTimes += time to c.getString(1) if (included(time)) { @@ -4489,11 +4493,8 @@ class TrainlogRepository( startedAt ) - put( - "session_type", - draft.sessionType - .wireValue - ) + put("session_type", legacySessionTypeWire(draft.sessionType)) + put("session_kind", draft.sessionType.wireValue) put("ended_at", endedAt) } @@ -4719,7 +4720,7 @@ class TrainlogRepository( SELECT s.session_id, s.started_at, - s.session_type, + s.session_kind, COUNT(se.id) FROM sessions AS s LEFT JOIN session_exercises AS se @@ -5680,7 +5681,7 @@ class TrainlogRepository( } private fun causalSessionSnapshot(db: SQLiteDatabase, sessionId: String): JSONArray? { - val header = db.rawQuery("SELECT id,session_id,started_at,ended_at,session_type,notes FROM sessions WHERE session_id=?", arrayOf(sessionId)).use { cursor -> + val header = db.rawQuery("SELECT id,session_id,started_at,ended_at,session_kind,notes FROM sessions WHERE session_id=?", arrayOf(sessionId)).use { cursor -> if (!cursor.moveToFirst()) null else Pair(cursor.getLong(0), causalCursorRow(cursor).also { it.remove(0) }) } ?: return null val occurrences = JSONArray() @@ -6030,7 +6031,11 @@ class TrainlogRepository( if (note.isNull("value")) null else note.getString("value")) } - private fun buildMobileExport(version: Int, completeCausalEnvelope: Boolean = false): String { + private fun buildMobileExport( + version: Int, + completeCausalEnvelope: Boolean = false, + cardioOnly: Boolean = false, + ): String { require(version in 1..4) if (!completeCausalEnvelope && database.readableDatabase.rawQuery( "SELECT 1 FROM sync_causal_state WHERE deleted=1 LIMIT 1", null, @@ -6059,8 +6064,11 @@ class TrainlogRepository( val sessionArray = JSONArray() /* CONTRACT: only completed `sessions` are part of mobile export v1; * active draft tables are intentionally outside the frozen artifact. */ + val sessionFilter = + if (cardioOnly) "session_kind='cardio'" else "session_kind<>'cardio'" db.rawQuery( - "SELECT id,session_id,started_at,session_type,ended_at,notes FROM sessions ORDER BY started_at ASC,id ASC;", + "SELECT id,session_id,started_at,session_type,ended_at,notes FROM sessions " + + "WHERE $sessionFilter ORDER BY started_at ASC,id ASC;", null, ).use { sessions -> while (sessions.moveToNext()) { @@ -6285,6 +6293,23 @@ class TrainlogRepository( /** Explicit staged enriched-history codec; normal synchronization remains V3. */ fun buildMobileExportV4Json(): String = buildMobileExport(4) + /** + * Cardio history is a separate companion so frozen mobile V4 never gains a + * third session_type. The internal V4-shaped fact projection is rewritten + * only at this new envelope boundary. + */ + internal fun buildCardioSessionsV1Json(): String { + val projected = JSONObject(buildMobileExport(4, true, cardioOnly = true)) + projected.put("format", "trainlog-cardio-sessions") + projected.put("version", 1) + projected.remove("body_observations") + val sessions = projected.getJSONArray("sessions") + for (index in 0 until sessions.length()) { + sessions.getJSONObject(index).put("session_type", "cardio") + } + return projected.toString() + } + /** Deterministic staged lifecycle artifact; raw form/UI columns are excluded. */ fun buildExecutionDraftExportV1Json(): String { val db = database.readableDatabase @@ -6293,7 +6318,7 @@ class TrainlogRepository( val drafts = JSONArray() db.rawQuery( "SELECT session_id,session_type,source_session_id,started_at,revision_id,parent_revision_id,notes " + - "FROM active_session_draft WHERE id=1;", null, + "FROM active_session_draft WHERE id=1 AND session_kind<>'cardio';", null, ).use { header -> if (header.moveToFirst()) { val sessionId = checkNotNull(if (header.isNull(0)) null else header.getString(0)) drafts.put(buildExecutionDraftJson(db, sessionId, header.getString(1), @@ -6677,7 +6702,7 @@ class TrainlogRepository( } val rowId = existingRowId ?: run { val values = ContentValues().apply { - put("session_id", sessionId); put("started_at", startedAt); put("session_type", type) + put("session_id", sessionId); put("started_at", startedAt); put("session_type", type); put("session_kind", type) if (version == 4 && !session.isNull("ended_at")) put("ended_at", session.getString("ended_at")) } db.insertOrThrow("sessions", null, values) @@ -8870,7 +8895,7 @@ class TrainlogRepository( val kept = if (hasMore) selected.take(limit) else selected val rows = kept.map { selectedRow -> db.rawQuery( - """SELECT se.id,s.session_id,se.entry_id,s.started_at,s.session_type, + """SELECT se.id,s.session_id,se.entry_id,s.started_at,s.session_kind, eq.equipment_id,eq.display_name,eq.load_semantics, se.recording_mode,se.tracking_mode,se.data_fields, ca.duration_seconds,ca.speed_kmh,ca.distance_km @@ -9025,7 +9050,7 @@ class TrainlogRepository( db.rawQuery( """ SELECT - d.session_type, + d.session_kind, d.set_count_text, d.reps_text, d.duration_text, @@ -9295,7 +9320,8 @@ class TrainlogRepository( val now = OffsetDateTime.now().toString() val values = ContentValues().apply { - put("session_type", draft.sessionType.wireValue) + put("session_type", legacySessionTypeWire(draft.sessionType)) + put("session_kind", draft.sessionType.wireValue) draft.sessionId?.let { put("session_id", it) } draft.startedAt?.let { put("started_at", it) } put("revision_id", draft.revisionId) @@ -9532,7 +9558,8 @@ class TrainlogRepository( /* CONTRACT: this exact explicit finalization instant anchors * H+; old NULL values remain unknown and are never backfilled. */ put("ended_at", forcedEndedAt ?: OffsetDateTime.now().toString()) - put("session_type", draft.sessionType.wireValue) + put("session_type", legacySessionTypeWire(draft.sessionType)) + put("session_kind", draft.sessionType.wireValue) } sessionRowId = db.insertOrThrow("sessions", null, sessionValues) } else { @@ -10665,7 +10692,7 @@ private class TrainlogDatabaseHelper( appContext, databaseName, null, - 28, + 29, ) { override fun onConfigure( db: SQLiteDatabase, @@ -10952,6 +10979,31 @@ private class TrainlogDatabaseHelper( seedActiveSessionTimeline(db) version = 28 } + if (version < 29 && newVersion >= 29) { + /* WHY: frozen legacy session_type admits only training/max_test. + * CONTRACT: v29 adds the canonical product session_kind without + * widening that legacy compatibility column. INVARIANT: old rows + * preserve their exact type and cardio never leaks into V3/V4. */ + if (!tableHasColumn(db, "sessions", "session_kind")) { + db.execSQL( + "ALTER TABLE sessions ADD COLUMN session_kind TEXT NOT NULL " + + "DEFAULT 'training' CHECK(session_kind IN('training','max_test','cardio'));", + ) + } + db.execSQL( + "UPDATE sessions SET session_kind=session_type;", + ) + if (!tableHasColumn(db, "active_session_draft", "session_kind")) { + db.execSQL( + "ALTER TABLE active_session_draft ADD COLUMN session_kind TEXT NOT NULL " + + "DEFAULT 'training' CHECK(session_kind IN('training','max_test','cardio'));", + ) + } + db.execSQL( + "UPDATE active_session_draft SET session_kind=session_type;", + ) + version = 29 + } if (version != newVersion) { error( @@ -11938,6 +11990,8 @@ private class TrainlogDatabaseHelper( 'max_test' ) ), + session_kind TEXT NOT NULL DEFAULT 'training' + CHECK(session_kind IN ('training','max_test','cardio')), source_program_id TEXT, source_program_session_id TEXT, CHECK( @@ -12159,6 +12213,8 @@ private class TrainlogDatabaseHelper( 'max_test' ) ), + session_kind TEXT NOT NULL DEFAULT 'training' + CHECK(session_kind IN ('training','max_test','cardio')), selected_exercise_row_id INTEGER REFERENCES exercises(id) ON DELETE SET NULL, 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 a4cce20..6f3a06a 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 @@ -9,17 +9,14 @@ enum class SessionType( val wireValue: String, ) { TRAINING("training"), - MAX_TEST("max_test"); + MAX_TEST("max_test"), + CARDIO("cardio"); companion object { fun fromWire( value: String, ): SessionType = - if (value == "max_test") { - MAX_TEST - } else { - TRAINING - } + entries.firstOrNull { it.wireValue == value } ?: TRAINING } } 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 bf3c490..e1d57c4 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 @@ -256,6 +256,19 @@ fun SessionScreen( ) }, ) + val cardio = currentDraft.sessionType == SessionType.CARDIO + TrainlogButton( + label = strings.getString(R.string.session_cardio), + modifier = Modifier.weight(1f), + style = if (cardio) TrainlogButtonStyle.SUCCESS else TrainlogButtonStyle.SECONDARY, + maxLines = 2, + onClick = { + persistDraft( + currentDraft.copy(sessionType = SessionType.CARDIO), + null, + ) + }, + ) } } @@ -279,23 +292,19 @@ fun SessionScreen( ) { TrainlogInfo( text = - strings.getString(R.string.session_type_value, - if ( - currentDraft.sessionType == - SessionType.MAX_TEST - ) { - strings.getString(R.string.session_max_test) - } else { - strings.getString(R.string.session_training) - }), + strings.getString( + R.string.session_type_value, + when (currentDraft.sessionType) { + SessionType.TRAINING -> strings.getString(R.string.session_training) + SessionType.MAX_TEST -> strings.getString(R.string.session_max_test) + SessionType.CARDIO -> strings.getString(R.string.session_cardio) + }, + ), color = - if ( - currentDraft.sessionType == - SessionType.MAX_TEST - ) { - colors.warning - } else { - colors.accent + when (currentDraft.sessionType) { + SessionType.MAX_TEST -> colors.warning + SessionType.CARDIO -> colors.success + SessionType.TRAINING -> colors.accent }, ) diff --git a/android/app/src/main/res/values-en/strings.xml b/android/app/src/main/res/values-en/strings.xml index f98a236..913d215 100644 --- a/android/app/src/main/res/values-en/strings.xml +++ b/android/app/src/main/res/values-en/strings.xml @@ -559,4 +559,5 @@ Medication unavailable. The night changed; reload before trying again. Unable to record the action. + Cardio diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 6e1f4a6..d7c468b 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -569,4 +569,5 @@ Médicament indisponible. La nuit a changé ; rechargez avant de recommencer. Enregistrement impossible. + Cardio 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 ab6fe4b..0cd085e 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt @@ -66,7 +66,7 @@ class AndroidV10PlanningMigrationTest { TrainlogRepository(context, name).useForTest { it.listSessions() } SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db -> - assertEquals(28, scalar(db, "PRAGMA user_version")) + assertEquals(29, 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 8d8f108..28a1608 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV11ExerciseAliasMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV11ExerciseAliasMigrationTest.kt @@ -69,7 +69,7 @@ class AndroidV11ExerciseAliasMigrationTest { migrated.close() } SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db -> - assertEquals(28, scalar(db, "PRAGMA user_version")) + assertEquals(29, scalar(db, "PRAGMA user_version")) /* v13 intentionally adds the frozen machine identities and * exercise-owned metadata; all non-exercise facts stay exact. */ val after = snapshot(db).filterKeys { it !in setOf("exercise_aliases", "exercise_profile_state", "exercise_profile_revisions") } diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV16AiSessionDraftMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV16AiSessionDraftMigrationTest.kt index f691ec4..74e34b5 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV16AiSessionDraftMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV16AiSessionDraftMigrationTest.kt @@ -78,7 +78,7 @@ class AndroidV16AiSessionDraftMigrationTest { migrated.close() } SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db -> - assertEquals(28, scalar(db, "PRAGMA user_version")) + assertEquals(29, scalar(db, "PRAGMA user_version")) assertEquals(before, listOf( scalar(db, "SELECT COUNT(*) FROM sessions"), scalar(db, "SELECT COUNT(*) FROM performed_sets"), diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV25ProgramExecutionMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV25ProgramExecutionMigrationTest.kt index ed8afd3..db676cf 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV25ProgramExecutionMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV25ProgramExecutionMigrationTest.kt @@ -69,7 +69,7 @@ class AndroidV25ProgramExecutionMigrationTest { reopened.close() } SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db -> - assertEquals(28, scalar(db, "PRAGMA user_version")) + assertEquals(29, scalar(db, "PRAGMA user_version")) assertEquals(0, scalar(db, "SELECT COUNT(*) FROM sessions WHERE source_program_id IS NOT NULL")) assertEquals("ok", text(db, "PRAGMA integrity_check")) db.rawQuery("PRAGMA foreign_key_check", null).use { cursor -> diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV26SleepMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV26SleepMigrationTest.kt index dab43be..dff83d1 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV26SleepMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV26SleepMigrationTest.kt @@ -35,7 +35,7 @@ class AndroidV26SleepMigrationTest { context.getDatabasePath(name).path, null, SQLiteDatabase.OPEN_READONLY, - ).use { assertEquals(28, it.version) } + ).use { assertEquals(29, it.version) } } finally { repository.close() context.deleteDatabase(name) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV27HeartRateMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV27HeartRateMigrationTest.kt index 3d58f51..674c923 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV27HeartRateMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV27HeartRateMigrationTest.kt @@ -45,7 +45,7 @@ class AndroidV27HeartRateMigrationTest { context.getDatabasePath(name).path, null, SQLiteDatabase.OPEN_READONLY, - ).use { assertEquals(28, it.version) } + ).use { assertEquals(29, it.version) } } finally { repository.close() context.deleteDatabase(name) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV28SessionTimelineMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV28SessionTimelineMigrationTest.kt index fec8981..0074618 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV28SessionTimelineMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV28SessionTimelineMigrationTest.kt @@ -78,7 +78,7 @@ class AndroidV28SessionTimelineMigrationTest { null, SQLiteDatabase.OPEN_READONLY, ).use { db -> - assertEquals(28, db.version) + assertEquals(29, db.version) db.rawQuery( "SELECT session_id,started_at,ended_at FROM session_timeline_sessions", null, diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV29CardioSessionMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV29CardioSessionMigrationTest.kt new file mode 100644 index 0000000..53843e0 --- /dev/null +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV29CardioSessionMigrationTest.kt @@ -0,0 +1,108 @@ +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.ActiveSessionDraft +import com.labfytools.trainlog.model.NewExerciseProfile +import com.labfytools.trainlog.model.RecordingMode +import com.labfytools.trainlog.model.SessionDraft +import com.labfytools.trainlog.model.SessionExerciseDraft +import com.labfytools.trainlog.model.SessionSetDraft +import com.labfytools.trainlog.model.TrackingMode +import java.util.UUID +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class AndroidV29CardioSessionMigrationTest { + private val context: Context = ApplicationProvider.getApplicationContext() + + @Test + fun v28BackfillsCanonicalSessionKindFromFrozenLegacyType() { + val name = "cardio-v28-" + UUID.randomUUID() + ".db" + var repository = TrainlogRepository(context, name) + try { + val exercise = + (repository.createExercise( + NewExerciseProfile( + "Cardio migration exercise", + RecordingMode.SETS, + TrackingMode.REPS, + 0, + ), + ) as CreateExerciseResult.Created).exercise + assertTrue( + repository.saveSession( + SessionDraft( + listOf( + SessionExerciseDraft( + exercise = exercise, + sets = listOf(SessionSetDraft(reps = 5)), + ), + ), + ), + ) is SaveSessionResult.Saved, + ) + assertEquals( + ActiveDraftMutationResult.Saved, + repository.saveActiveSessionDraft( + ActiveSessionDraft( + exercises = + listOf( + SessionExerciseDraft( + exercise = exercise, + sets = listOf(SessionSetDraft(reps = 6)), + ), + ), + ), + ), + ) + repository.close() + + SQLiteDatabase.openDatabase( + context.getDatabasePath(name).path, + null, + SQLiteDatabase.OPEN_READWRITE, + ).use { db -> + db.execSQL("UPDATE sessions SET session_kind='cardio'") + db.execSQL("UPDATE active_session_draft SET session_kind='cardio'") + db.version = 28 + } + + repository = TrainlogRepository(context, name) + assertEquals(1, repository.listSessions().size) + SQLiteDatabase.openDatabase( + context.getDatabasePath(name).path, + null, + SQLiteDatabase.OPEN_READONLY, + ).use { db -> + assertEquals(29, db.version) + db.rawQuery( + "SELECT session_type,session_kind FROM sessions", + null, + ).use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals("training", cursor.getString(0)) + assertEquals("training", cursor.getString(1)) + } + db.rawQuery( + "SELECT session_type,session_kind FROM active_session_draft WHERE id=1", + null, + ).use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals("training", cursor.getString(0)) + assertEquals("training", cursor.getString(1)) + } + } + } finally { + repository.close() + context.deleteDatabase(name) + } + } +} 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 1e9f585..e86da61 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt @@ -328,7 +328,7 @@ class BodyZonesTest { SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null, SQLiteDatabase.OPEN_READONLY).use { db -> db.rawQuery("PRAGMA user_version", null).use { cursor -> - assertTrue(cursor.moveToFirst()); assertEquals(28, cursor.getInt(0)) + assertTrue(cursor.moveToFirst()); assertEquals(29, cursor.getInt(0)) } db.rawQuery("SELECT id FROM exercises", null).use { cursor -> assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0)) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/BridgeBackupMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/BridgeBackupMigrationTest.kt index 60dec75..572b404 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/BridgeBackupMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/BridgeBackupMigrationTest.kt @@ -43,7 +43,7 @@ class BridgeBackupMigrationTest { null, android.database.sqlite.SQLiteDatabase.OPEN_READONLY, ) - .use { assertEquals(28, it.version) } + .use { assertEquals(29, it.version) } } finally { repository.close() context.deleteDatabase(name) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/CardioSessionSyncGenerationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/CardioSessionSyncGenerationTest.kt new file mode 100644 index 0000000..769ed2d --- /dev/null +++ b/android/app/src/test/java/com/labfytools/trainlog/data/CardioSessionSyncGenerationTest.kt @@ -0,0 +1,194 @@ +package com.labfytools.trainlog.data + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +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.SessionType +import com.labfytools.trainlog.model.TrackingMode +import java.nio.file.Files +import java.util.UUID +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class CardioSessionSyncGenerationTest { + private val context: Context = ApplicationProvider.getApplicationContext() + + @Test + fun dedicatedCardioCompanionImportsWithoutWideningFrozenHistory() { + val sourceName = "cardio-sync-" + UUID.randomUUID() + ".db" + val root = Files.createTempDirectory("trainlog-cardio-sync-").toFile() + val source = TrainlogRepository(context, sourceName) + try { + val exercise = + (source.createExercise( + NewExerciseProfile( + "Cardio sync exercise", + RecordingMode.SETS, + TrackingMode.REPS, + 0, + ), + ) as CreateExerciseResult.Created).exercise + val entry = + SessionExerciseDraft( + exercise = exercise, + sets = listOf(SessionSetDraft(reps = 15)), + ) + assertEquals( + ActiveDraftMutationResult.Saved, + source.saveActiveSessionDraft( + ActiveSessionDraft( + sessionType = SessionType.CARDIO, + exercises = listOf(entry), + ), + ), + ) + assertTrue( + source.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved, + ) + val sessionId = source.listSessions().single().sessionId + + val repositoryRoot = + generateSequence(java.io.File(checkNotNull(System.getProperty("user.dir")))) { + it.parentFile + }.first { java.io.File(it, "tools/sync_generation_exchange.py").isFile } + + fun run(vararg command: String): String { + val process = + ProcessBuilder(*command) + .directory(repositoryRoot) + .redirectErrorStream(true) + .start() + val output = process.inputStream.bufferedReader().readText() + assertEquals( + "command failed: " + command.joinToString(" ") + "\n" + output, + 0, + process.waitFor(), + ) + return output + } + + val desktopDb = java.io.File(root, "desktop.sqlite") + run( + java.io.File( + repositoryRoot, + "build/tui/sync-generation-fixture", + ).absolutePath, + desktopDb.absolutePath, + ) + val desktopPeer = + Regex("PEER_ID=(peer_[^\\s]+)") + .find( + run( + "python3", + java.io.File( + repositoryRoot, + "tools/sync_generation_exchange.py", + ).absolutePath, + "peer-id", + "--database", + desktopDb.absolutePath, + ), + )!! + .groupValues[1] + + val service = SyncGenerationService(source) + val captured = service.capture(root, desktopPeer) + val history = + JSONObject( + java.io.File( + captured.stagingDirectory, + "history-v4.json", + ).readText(), + ) + assertEquals(0, history.getJSONArray("sessions").length()) + + val cardio = + JSONObject( + java.io.File( + captured.stagingDirectory, + "cardio-sessions-v1.json", + ).readText(), + ) + assertEquals("trainlog-cardio-sessions", cardio.getString("format")) + assertEquals(1, cardio.getJSONArray("sessions").length()) + assertEquals( + "cardio", + cardio + .getJSONArray("sessions") + .getJSONObject(0) + .getString("session_type"), + ) + + val published = service.publish(captured, java.io.File(root, "objects")) + val ackFile = java.io.File(root, "ack.json") + run( + "python3", + java.io.File( + repositoryRoot, + "tools/sync_generation_exchange.py", + ).absolutePath, + "consume-desktop", + published.absolutePath, + "--database", + desktopDb.absolutePath, + "--ack-output", + ackFile.absolutePath, + ) + val ack = JSONObject(ackFile.readText()) + assertEquals( + "desktop ACK diagnostic: " + ack.getString("diagnostic"), + "consumed", + ack.getString("result"), + ) + assertEquals( + "training|cardio|15", + run( + "sqlite3", + desktopDb.absolutePath, + "SELECT s.session_type || '|' || s.session_kind || '|' || ps.reps " + + "FROM sessions s JOIN session_exercises se ON se.session_row_id=s.id " + + "JOIN performed_sets ps ON ps.session_exercise_row_id=se.id " + + "WHERE s.session_id='$sessionId';", + ).trim(), + ) + + assertEquals( + "acknowledged", + service.acceptAcknowledgement(ackFile.readBytes()), + ) + assertEquals( + ack.toString(), + JSONObject( + run( + "python3", + java.io.File( + repositoryRoot, + "tools/sync_generation_exchange.py", + ).absolutePath, + "consume-desktop", + published.absolutePath, + "--database", + desktopDb.absolutePath, + "--ack-output", + java.io.File(root, "replay-ack.json").absolutePath, + ).let { java.io.File(root, "replay-ack.json").readText() }, + ).toString(), + ) + } finally { + source.close() + context.deleteDatabase(sourceName) + root.deleteRecursively() + } + } +} diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/CardioSessionTypeTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/CardioSessionTypeTest.kt new file mode 100644 index 0000000..bec47df --- /dev/null +++ b/android/app/src/test/java/com/labfytools/trainlog/data/CardioSessionTypeTest.kt @@ -0,0 +1,95 @@ +package com.labfytools.trainlog.data + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +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.SessionType +import com.labfytools.trainlog.model.TrackingMode +import java.util.UUID +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class CardioSessionTypeTest { + private val context: Context = ApplicationProvider.getApplicationContext() + + @Test + fun cardioIsCanonicalLocallyAndExcludedFromFrozenMobileHistory() { + val name = "cardio-kind-" + UUID.randomUUID() + ".db" + val repository = TrainlogRepository(context, name) + try { + val exercise = + (repository.createExercise( + NewExerciseProfile( + "Cardio kind exercise", + RecordingMode.SETS, + TrackingMode.REPS, + 0, + ), + ) as CreateExerciseResult.Created).exercise + val occurrence = + SessionExerciseDraft( + exercise = exercise, + sets = listOf(SessionSetDraft(reps = 1)), + ) + assertEquals( + ActiveDraftMutationResult.Saved, + repository.saveActiveSessionDraft( + ActiveSessionDraft( + sessionType = SessionType.CARDIO, + exercises = listOf(occurrence), + ), + ), + ) + val loaded = + repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded + assertEquals(SessionType.CARDIO, loaded.draft.sessionType) + + val db = context.openOrCreateDatabase(name, Context.MODE_PRIVATE, null) + db.rawQuery( + "SELECT session_type,session_kind FROM active_session_draft WHERE id=1", + null, + ).use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals("training", cursor.getString(0)) + assertEquals("cardio", cursor.getString(1)) + } + db.close() + + assertTrue( + repository.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved, + ) + assertEquals( + SessionType.CARDIO, + repository.listSessions().single().sessionType, + ) + + val frozen = JSONObject(repository.buildMobileExportV4Json()) + assertEquals(0, frozen.getJSONArray("sessions").length()) + + val verify = context.openOrCreateDatabase(name, Context.MODE_PRIVATE, null) + verify.rawQuery( + "SELECT session_type,session_kind FROM sessions", + null, + ).use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals("training", cursor.getString(0)) + assertEquals("cardio", cursor.getString(1)) + } + verify.close() + } finally { + repository.close() + context.deleteDatabase(name) + } + } +} diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/SessionPreparationExchangeTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/SessionPreparationExchangeTest.kt index 8f12961..1edfaf0 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/SessionPreparationExchangeTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/SessionPreparationExchangeTest.kt @@ -360,7 +360,7 @@ class SessionPreparationExchangeTest { repository = TrainlogRepository(context, name) assertEquals(1, repository.listPreparedSessions().size) repository.close() - assertEquals("28", scalarText(name, "PRAGMA user_version")) + assertEquals("29", scalarText(name, "PRAGMA user_version")) assertEquals( "0", scalarText(name, "SELECT COUNT(*) FROM session_preparation_withdrawals"), diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/StatisticsRepositoryTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/StatisticsRepositoryTest.kt index 38a20ec..b3c9156 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/StatisticsRepositoryTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/StatisticsRepositoryTest.kt @@ -296,7 +296,7 @@ class StatisticsRepositoryTest { } private fun session(db: SQLiteDatabase, id: String, timestamp: String, type: String = "training") { - db.execSQL("INSERT INTO sessions(session_id,started_at,session_type) VALUES(?,?,?)", arrayOf(id, timestamp, type)) + db.execSQL("INSERT INTO sessions(session_id,started_at,session_type,session_kind) VALUES(?,?,?,?)", arrayOf(id, timestamp, type, type)) } private fun occurrence( diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/SyncGenerationServiceTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/SyncGenerationServiceTest.kt index 234d64e..0b710df 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/SyncGenerationServiceTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/SyncGenerationServiceTest.kt @@ -509,7 +509,7 @@ class SyncGenerationServiceTest { .use { db -> db.rawQuery("PRAGMA user_version", null).use { assertTrue(it.moveToFirst()) - assertEquals(28, it.getInt(0)) + assertEquals(29, it.getInt(0)) } db.rawQuery("SELECT COUNT(*) FROM exercises", null).use { assertTrue(it.moveToFirst()) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt index 9c46d8e..6e4cad2 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt @@ -2260,7 +2260,7 @@ class TrainlogRepositoryDraftTest { ).use { db -> db.rawQuery("PRAGMA user_version;", null).use { cursor -> assertTrue(cursor.moveToFirst()) - assertEquals(28, cursor.getInt(0)) + assertEquals(29, cursor.getInt(0)) } db.rawQuery( "SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " + @@ -2387,7 +2387,7 @@ class TrainlogRepositoryDraftTest { ).use { db -> db.rawQuery("PRAGMA user_version;", null).use { cursor -> assertTrue(cursor.moveToFirst()) - assertEquals(28, cursor.getInt(0)) + assertEquals(29, cursor.getInt(0)) } db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor -> assertTrue(cursor.moveToFirst()) diff --git a/docs/current_state.md b/docs/current_state.md index 168cce9..6b83d24 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -112,8 +112,8 @@ parallel implementation of its rules. | Boundary | Current state | |---|---| | Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` | -| Desktop SQLite | schema v31; session exercise timeline import is additive in v31 | -| Android SQLite | schema v28; active training timeline/outbox is additive in v28 | +| Desktop SQLite | schema v32; canonical `session_kind` adds true cardio sessions while frozen legacy `session_type` remains training/max_test | +| Android SQLite | schema v29; canonical `session_kind` adds true cardio sessions while legacy draft/history codecs remain training/max_test | | Mobile snapshot | V3 active; V1/V2 readable legacy inputs; explicit V4 codec staged, not selected by transport | | Desktop terminal backend | Notcurses only | | Trainlog product version | `0.1.5` development, synchronized across Android and desktop; latest stable release: `v0.1.4` | @@ -152,7 +152,8 @@ parallel implementation of its rules. | Cardio persistent acquisition | `TRAINLOG_CARDIO_PERSISTENT_ACQUISITION_V1=PASS`: selected sensor persists, dedicated connected-device foreground service reconnects, stale BPM is cleared, and the global Android `♥ BPM` indicator is grey/orange/green by connection state; real CYCPLUS restart and Bluetooth-off/on recovery validated on private vc40 | | Training timeline | `TRAINLOG_CARDIO_TRAINING_TIMELINE_V1=PASS`: real session bounds reuse canonical lifecycle timestamps; exercise start/end are explicit user actions; live BPM samples snapshot the active stable `entry_id`; session finalization refuses an unclosed exercise; timeline sync is Android→desktop idempotent | | Sleep Android one-tap | `TRAINLOG_SLEEP_ANDROID_ONE_TAP_V1=SOFTWARE_PASS / DEVICE_VISUAL_PENDING`: existing Sleep Diary V1 now has one-tap Couché / medication / Réveil / Levé, guarded 10 s undo, full correction route and sleep-entry-owned cardio capture; vc41 installed without data clear, but final visual device review was blocked by the phone lock screen | -| Current operational cursor | `TRAINLOG_CARDIO_SESSION_TYPE_V1`: add the true `cardio` session type without silently widening frozen legacy formats | +| Cardio session type | `TRAINLOG_CARDIO_SESSION_TYPE_V1=PASS`: Android v29 + desktop v32 use canonical `session_kind=cardio`; frozen mobile/history and draft formats keep legacy training/max_test; dedicated `trainlog-cardio-sessions` V1 syncs completed cardio sessions Android→desktop idempotently | +| Current operational cursor | `TRAINLOG_CARDIO_CALIBRATION_V1`: add the first versioned Calibration cardio exercise, observed peak BPM and recovery timeline without claiming a physiological maximum | | Complete synchronization gap contract | Frozen dependency contract; operational USB/Drive slices required by v0.1.2 are delivered | | Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` | | Synchronization characterization | `TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` | diff --git a/docs/database.md b/docs/database.md index 05476df..fdc52c8 100644 --- a/docs/database.md +++ b/docs/database.md @@ -1,5 +1,27 @@ # Desktop database +## Schema v32: canonical cardio session kind + +Schema v32 adds the product-owned `sessions.session_kind` column with the +explicit domain `training | max_test | cardio`. The pre-existing +`sessions.session_type` column remains intentionally frozen to +`training | max_test` for compatibility with historical codecs and MAX +semantics. A cardio row therefore stores `session_type=training` only as a +legacy compatibility projection and `session_kind=cardio` as the canonical +product fact. + +The v31→v32 migration introspects historical `sessions` layouts before +backfill. When the legacy `session_type` column exists it seeds +`session_kind` from it; very old historical fixtures that legitimately lack +that column keep the session kind produced by their earlier migration chain. +No existing session identity, occurrence, set, timeline, Program provenance or +heart-rate fact is rewritten. + +Completed cardio sessions synchronize Android→desktop through the separate +optional `trainlog-cardio-sessions` V1 generation companion. The frozen +mobile/history V4 and execution-draft formats are not widened to a third +session type. + ## Schema v31: synchronized session exercise timeline Schema v31 additively owns Android-recorded execution timing for completed diff --git a/docs/roadmap.md b/docs/roadmap.md index 239fc7f..b346b7b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -69,7 +69,7 @@ sensor. Heart-rate ingestion must not reuse or overload the frozen Trainlog Bluetooth synchronization transport contract. ```text -CURRENT_OPERATIONAL_CURSOR=TRAINLOG_CARDIO_SESSION_TYPE_V1 +CURRENT_OPERATIONAL_CURSOR=TRAINLOG_CARDIO_CALIBRATION_V1 ``` The focused `TRAINLOG_SYNC_CAUSAL_DELETE_V1_CLOSEOUT` is complete before this diff --git a/docs/sync_exchange.md b/docs/sync_exchange.md index 491e70e..872e88b 100644 --- a/docs/sync_exchange.md +++ b/docs/sync_exchange.md @@ -1105,7 +1105,29 @@ Heart-rate samples use the same active `entry_id` at acquisition time. Samples between exercise end and the next exercise start retain no fabricated exercise owner and therefore remain factual rest/transition data. -## 21. Legacy active-protocol limitations +## 21. Cardio sessions companion V1 + +`trainlog-cardio-sessions` V1 is an optional Android→desktop +full-generation participant. It carries completed sessions whose canonical +Android `session_kind` is `cardio`. These sessions are deliberately absent +from the frozen `history-v4` artifact. + +The companion reuses the same bounded exercise/occurrence/set/continuous fact +shape internally but owns its own envelope and requires every public +`session_type` to be exactly `cardio`. Desktop schema v32 imports those +facts transactionally, stores legacy `session_type=training` only for +compatibility, and stores canonical `session_kind=cardio`. + +Exact replay is idempotent. Reusing a cardio `session_id` that already belongs +to non-cardio canonical history is rejected. The companion is consumed before +session-timeline and heart-rate participants in the same generation, so the +stable `session_id` and occurrence facts exist before their timing and sensor +facts are applied. + +Programs, manual preparations, AI execution drafts, mobile V3/V4 and +`TRAINLOG_FORMAT_V1` are not widened by this companion. + +## 22. Legacy active-protocol limitations The Web end-to-end tranche adds an explicit opt-in application orchestrator. `POST /api/v1/sync` admits one correlated run and `GET /api/v1/sync/status` is diff --git a/docs/tests.md b/docs/tests.md index cc485c2..fa445ef 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -1770,3 +1770,31 @@ The private Android installation was advanced non-destructively to Trainlog data clear. The device locked before the final visual Sleep-screen inspection, so no lock bypass was attempted and no real Sleep business button was pressed; that visual review remains explicitly pending. + +## Cardio session type V1 validation + +`TRAINLOG_CARDIO_SESSION_TYPE_V1=PASS` covers the new canonical session kind +without widening frozen historical codecs. Android schema v29 migration tests +prove that v28 training/max-test rows backfill from the legacy +`session_type`, including a structurally adversarial retained-column fixture. +A product-level Android regression creates, reloads and finalizes a real +`SessionType.CARDIO` draft, verifies legacy `session_type=training` plus +canonical `session_kind=cardio`, and proves the cardio session is absent from +`history-v4`. + +The end-to-end generation regression proves the complementary path: +`history-v4` contains zero cardio sessions, `cardio-sessions-v1.json` +contains the completed cardio session, the production desktop generation +consumer imports it as legacy training/canonical cardio with its performed set, +the correlated ACK is accepted, and exact replay remains idempotent. + +Desktop schema v32 migration coverage validates the additive canonical column, +historical-layout tolerance and foreign-key integrity. +`cardio_session_exchange` separately covers strict cardio-only validation, +exact replay and rejection of a stable identity colliding with non-cardio +history. + +The 2026-09-23 closeout passes the complete native Meson inventory: +**106/106 passed, 0 failed**. The complete Android JVM inventory reports +**277 tests: 272 passed, five skipped, zero failures/errors**; `assembleDebug` +also succeeds. The five skips remain optional historical/external fixture gates. diff --git a/tests/test_cardio_session_exchange.py b/tests/test_cardio_session_exchange.py new file mode 100644 index 0000000..10e62e5 --- /dev/null +++ b/tests/test_cardio_session_exchange.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 + +import json +import sqlite3 +import subprocess +import sys +import tempfile +import unittest +from copy import deepcopy +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "tools")) + +import cardio_session_exchange as cardio +from trainlog_sqlite import connect_database +import import_equipment_definitions + + +SESSION = "se_11111111-1111-4111-8111-111111111111" +EXERCISE = "ex_22222222-2222-4222-8222-222222222222" +ENTRY = "sxe_33333333-3333-4333-8333-333333333333" + + +def document() -> dict: + return { + "format": "trainlog-cardio-sessions", + "version": 1, + "generated_at": "2026-09-23T10:00:00+02:00", + "exercises": [ + { + "exercise_id": EXERCISE, + "name": "Cardio exchange exercise", + "recording_mode": "sets", + "tracking_mode": "reps", + "data_fields": 0, + } + ], + "sessions": [ + { + "session_id": SESSION, + "started_at": "2026-09-23T09:00:00+02:00", + "ended_at": "2026-09-23T09:30:00+02:00", + "session_type": "cardio", + "note": None, + "exercises": [ + { + "exercise_id": EXERCISE, + "name": "Cardio exchange exercise", + "recording_mode": "sets", + "tracking_mode": "reps", + "data_fields": 0, + "load_mode": "none", + "rest_seconds": 0, + "entry_id": ENTRY, + "position": 0, + "equipment_id": None, + "target": None, + "note": None, + "sets": [{"reps": 12}], + } + ], + } + ], + } + + +class CardioSessionExchangeTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.db_path = Path(self.temp.name) / "trainlog.sqlite" + fixture = ROOT / "build/tui/sync-generation-fixture" + subprocess.run([str(fixture), str(self.db_path)], check=True, capture_output=True) + self.db = connect_database(self.db_path) + self.known_equipment = import_equipment_definitions.supplied_ids( + ROOT / "catalog/equipment-v1.json" + ) + + def tearDown(self): + self.db.close() + self.temp.cleanup() + + def test_import_and_exact_replay_keep_legacy_type_frozen(self): + payload = cardio.validate(document(), self.known_equipment) + first = cardio.apply(self.db, payload, complete_causal_envelope=True) + second = cardio.apply(self.db, payload, complete_causal_envelope=True) + self.assertEqual(1, first["sessions_imported"]) + self.assertEqual(0, second["sessions_imported"]) + self.assertEqual( + ("training", "cardio"), + tuple( + self.db.execute( + "SELECT session_type,session_kind FROM sessions WHERE session_id=?", + (SESSION,), + ).fetchone() + ), + ) + self.assertEqual( + (ENTRY, 12), + tuple( + self.db.execute( + "SELECT se.entry_id,ps.reps FROM session_exercises se " + "JOIN performed_sets ps ON ps.session_exercise_row_id=se.id " + "JOIN sessions s ON s.id=se.session_row_id WHERE s.session_id=?", + (SESSION,), + ).fetchone() + ), + ) + + def test_rejects_non_cardio_session_type(self): + changed = document() + changed["sessions"][0]["session_type"] = "training" + with self.assertRaisesRegex(ValueError, "must be cardio"): + cardio.validate(changed, self.known_equipment) + + def test_rejects_identity_collision_with_non_cardio_history(self): + payload = cardio.validate(document(), self.known_equipment) + cardio.apply(self.db, payload, complete_causal_envelope=True) + self.db.execute( + "UPDATE sessions SET session_kind='training' WHERE session_id=?", + (SESSION,), + ) + with self.assertRaisesRegex(ValueError, "collides with non-cardio"): + cardio.apply(self.db, payload, complete_causal_envelope=True) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_sync_deployment_tools.py b/tests/test_sync_deployment_tools.py index 5b0f496..1c4b630 100644 --- a/tests/test_sync_deployment_tools.py +++ b/tests/test_sync_deployment_tools.py @@ -66,12 +66,13 @@ class DeploymentToolsTest(unittest.TestCase): self.assertEqual(result.returncode, 0, result.stderr) inventory = json.loads((output / "candidate-inventory.json").read_text()) self.assertEqual(inventory["product_version"], "0.1.5") - self.assertEqual(inventory["desktop_schema"], 31) - self.assertEqual(inventory["android_schema"], 28) + self.assertEqual(inventory["desktop_schema"], 32) + self.assertEqual(inventory["android_schema"], 29) self.assertIn("session-preparations-v2", inventory["protocols"]) self.assertIn("programs-v1", inventory["protocols"]) self.assertIn("program-executions-v1", inventory["protocols"]) self.assertIn("sleep-diary-v1", inventory["protocols"]) + self.assertIn("cardio-sessions-v1", inventory["protocols"]) self.assertIn("heart-rate-v1", inventory["protocols"]) self.assertIn("session-timeline-v1", inventory["protocols"]) self.assertIn("bluetooth-files-v1", inventory["protocols"]) diff --git a/tools/cardio_session_exchange.py b/tools/cardio_session_exchange.py new file mode 100644 index 0000000..4b82f3e --- /dev/null +++ b/tools/cardio_session_exchange.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +"""Validate and import Android-owned dedicated cardio sessions V1.""" + +import json +from pathlib import Path + +import import_mobile_export + + +FORMAT = "trainlog-cardio-sessions" +VERSION = 1 +MAX_BYTES = 64 * 1024 * 1024 +ROOT_KEYS = {"format", "version", "generated_at", "exercises", "sessions"} + + +class CardioSessionError(ValueError): + pass + + +def fail(message: str) -> None: + raise CardioSessionError(message) + + +def unique_object(pairs): + result = {} + for key, value in pairs: + if key in result: + fail("duplicate JSON field: " + key) + result[key] = value + return result + + +def load(path: Path) -> dict: + if path.stat().st_size > MAX_BYTES: + fail("cardio session artifact exceeds 64 MiB") + try: + root = json.loads( + path.read_text(encoding="utf-8"), + object_pairs_hook=unique_object, + ) + except (OSError, UnicodeError, json.JSONDecodeError) as error: + raise CardioSessionError("invalid cardio session JSON") from error + if not isinstance(root, dict) or set(root) != ROOT_KEYS: + fail("invalid cardio session envelope") + if root["format"] != FORMAT or type(root["version"]) is not int or root["version"] != VERSION: + fail("unsupported cardio session format") + if not isinstance(root["exercises"], list) or not isinstance(root["sessions"], list): + fail("invalid cardio session arrays") + for index, session in enumerate(root["sessions"]): + if not isinstance(session, dict) or session.get("session_type") != "cardio": + fail(f"sessions[{index}].session_type must be cardio") + return root + + +def mobile_projection(root: dict) -> dict: + sessions = [] + for source in root["sessions"]: + session = dict(source) + session["session_type"] = "training" + sessions.append(session) + return { + "format": "trainlog-mobile-export", + "version": 4, + "generated_at": root["generated_at"], + "exercises": root["exercises"], + "sessions": sessions, + "body_observations": [], + } + + +def validate(root: dict, known_equipment_ids: set[str]) -> dict: + if not isinstance(root, dict) or set(root) != ROOT_KEYS: + fail("invalid cardio session envelope") + if root.get("format") != FORMAT or type(root.get("version")) is not int or root["version"] != VERSION: + fail("unsupported cardio session format") + if not isinstance(root.get("sessions"), list) or not isinstance(root.get("exercises"), list): + fail("invalid cardio session arrays") + for index, session in enumerate(root["sessions"]): + if not isinstance(session, dict) or session.get("session_type") != "cardio": + fail(f"sessions[{index}].session_type must be cardio") + projection = mobile_projection(root) + try: + import_mobile_export.validate_payload(projection, known_equipment_ids) + except Exception as error: + raise CardioSessionError(str(error)) from error + return root + + +def apply( + connection, + root: dict, + *, + complete_causal_envelope: bool = False, +) -> dict: + session_ids = [session["session_id"] for session in root["sessions"]] + for session_id in session_ids: + row = connection.execute( + "SELECT session_kind FROM sessions WHERE session_id=?", + (session_id,), + ).fetchone() + if row is not None and row[0] != "cardio": + fail("cardio session identity collides with non-cardio history") + + projection = mobile_projection(root) + try: + report = import_mobile_export.apply_payload( + connection, + projection, + complete_causal_envelope=complete_causal_envelope, + ) + except Exception as error: + raise CardioSessionError(str(error)) from error + + for session_id in session_ids: + row = connection.execute( + "SELECT session_kind FROM sessions WHERE session_id=?", + (session_id,), + ).fetchone() + if row is not None: + if row[0] not in ("training", "cardio"): + fail("cardio session identity has incompatible stored kind") + connection.execute( + "UPDATE sessions SET session_kind='cardio' WHERE session_id=?", + (session_id,), + ) + return report diff --git a/tools/causal_delete_exchange.py b/tools/causal_delete_exchange.py index f92cd78..76c22fa 100644 --- a/tools/causal_delete_exchange.py +++ b/tools/causal_delete_exchange.py @@ -38,8 +38,8 @@ def digest(value) -> str: def require_schema(db: sqlite3.Connection) -> None: version = db.execute("PRAGMA user_version").fetchone()[0] - if version not in (20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31): - raise CausalError(f"desktop schema v20-v31 required, found v{version}") + if version not in (20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32): + raise CausalError(f"desktop schema v20-v32 required, found v{version}") def target_snapshot(db: sqlite3.Connection, kind: str, target: str): diff --git a/tools/execution_draft_exchange.py b/tools/execution_draft_exchange.py index 895b6cd..1f3651a 100644 --- a/tools/execution_draft_exchange.py +++ b/tools/execution_draft_exchange.py @@ -182,8 +182,8 @@ def causal_payload(draft): def require_schema(connection): version = connection.execute("PRAGMA user_version").fetchone()[0] - if version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31): - raise LifecycleError(f"desktop schema v19-v31 required, found v{version}") + if version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32): + raise LifecycleError(f"desktop schema v19-v32 required, found v{version}") def import_document(connection, document, own_transaction=True): diff --git a/tools/export_ai_history.py b/tools/export_ai_history.py index 4b8f3ae..e66591f 100644 --- a/tools/export_ai_history.py +++ b/tools/export_ai_history.py @@ -12,7 +12,7 @@ from pathlib import Path from trainlog_sqlite import configure_connection -SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31) +SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32) MEASUREMENT_FIELDS = ( "body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm", "hips_cm", "left_arm_cm", "right_arm_cm", "left_forearm_cm", @@ -125,7 +125,7 @@ def build_export(connection, generated_at): """Build the raw export using only explicitly ordered SELECT statements.""" schema_version = connection.execute("PRAGMA user_version").fetchone()[0] if schema_version not in SUPPORTED_SCHEMA_VERSIONS: - raise ValueError("schema desktop v15-v31 requis") + raise ValueError("schema desktop v15-v32 requis") root = { "format": "TRAINLOG_AI_EXPORT", diff --git a/tools/export_ai_session_drafts.py b/tools/export_ai_session_drafts.py index 5efc032..be6bd08 100644 --- a/tools/export_ai_session_drafts.py +++ b/tools/export_ai_session_drafts.py @@ -20,9 +20,9 @@ ID_PREFIX = "aid_" def ensure_publication_state(connection): """Install the additive v18 cursor for direct syncd/tool invocations.""" owned_transaction = not connection.in_transaction - supported_versions = (18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31) + supported_versions = (18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - raise ValueError("schema desktop v18-v31 requis") + raise ValueError("schema desktop v18-v32 requis") columns = {row[1] for row in connection.execute( "PRAGMA table_info(ai_session_drafts)")} if "published_at" not in columns: diff --git a/tools/export_equipment_associations.py b/tools/export_equipment_associations.py index aa015a2..3ec9c7f 100644 --- a/tools/export_equipment_associations.py +++ b/tools/export_equipment_associations.py @@ -28,9 +28,9 @@ def main(): args = parser.parse_args() connection = connect_database(args.database) try: - supported_versions = tuple(range(8, 32)) + supported_versions = tuple(range(8, 33)) if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions: - raise ValueError("schema desktop v8 à v31 requis") + raise ValueError("schema desktop v8 à v32 requis") known_equipment = load_supplied_equipment_ids(args.catalog) known_equipment.update(row[0] for row in connection.execute( "SELECT equipment_id FROM custom_equipment")) diff --git a/tools/export_equipment_definitions.py b/tools/export_equipment_definitions.py index eb4c7c7..2b8662d 100644 --- a/tools/export_equipment_definitions.py +++ b/tools/export_equipment_definitions.py @@ -24,9 +24,9 @@ def main(complete_causal_envelope=False): connection = connect_database(args.database) connection.row_factory = sqlite3.Row try: - supported_versions = tuple(range(8, 32)) + supported_versions = tuple(range(8, 33)) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - raise ValueError("schema desktop v8 à v31 requis") + raise ValueError("schema desktop v8 à v32 requis") if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone(): raise ValueError("causal equipment protection requires the staged artifact") equipment = [dict(row) for row in connection.execute( diff --git a/tools/export_exercise_aliases.py b/tools/export_exercise_aliases.py index 8ea770b..816cd7e 100644 --- a/tools/export_exercise_aliases.py +++ b/tools/export_exercise_aliases.py @@ -17,8 +17,8 @@ def main(): con = connect_database(args.database) try: if con.execute("PRAGMA user_version").fetchone()[0] not in ( - 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31): - raise ValueError("schema desktop v12-v31 requis") + 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32): + raise ValueError("schema desktop v12-v32 requis") rows = con.execute( "SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases " "ORDER BY source_exercise_id COLLATE BINARY" diff --git a/tools/export_exercise_body_zones.py b/tools/export_exercise_body_zones.py index 365e0e9..a4c4644 100644 --- a/tools/export_exercise_body_zones.py +++ b/tools/export_exercise_body_zones.py @@ -41,9 +41,9 @@ def main(complete_causal_envelope=False): connection = connect_database(args.database) connection.row_factory = sqlite3.Row try: - supported_versions = tuple(range(11, 32)) + supported_versions = tuple(range(11, 33)) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - raise ValueError("schema desktop v11-v31 requis") + raise ValueError("schema desktop v11-v32 requis") if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone(): raise ValueError("causal BODY ZONES protection requires the staged artifact") exercises = [] diff --git a/tools/export_exercise_profile_state.py b/tools/export_exercise_profile_state.py index b51cfa3..ad53c7f 100644 --- a/tools/export_exercise_profile_state.py +++ b/tools/export_exercise_profile_state.py @@ -25,7 +25,7 @@ def main(): con.row_factory = sqlite3.Row try: version = con.execute("PRAGMA user_version").fetchone()[0] - if version not in (17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31): + if version not in (17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32): raise SystemExit(f"EXERCISE_PROFILE_STATE_EXPORT=FAIL schema={version}") rows = con.execute(""" SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields, diff --git a/tools/export_pc_catalog.py b/tools/export_pc_catalog.py index 1474708..1dc0ced 100755 --- a/tools/export_pc_catalog.py +++ b/tools/export_pc_catalog.py @@ -71,7 +71,7 @@ def main(): # CONTRACT: v8 adds only desktop-local custom equipment. The PC # catalogue artifact is unchanged, but it must read the current # canonical desktop schema rather than accept a stale pre-v8 database. - if version not in range(8, 32): + if version not in range(8, 33): raise SystemExit( "PC_CATALOG_EXPORT=FAIL " f"schema={version}" diff --git a/tools/export_pc_mobile.py b/tools/export_pc_mobile.py index bcc3ec4..a0c6afb 100644 --- a/tools/export_pc_mobile.py +++ b/tools/export_pc_mobile.py @@ -83,12 +83,12 @@ def main(complete_causal_envelope=False): con.row_factory = sqlite3.Row try: schema_version = con.execute("PRAGMA user_version").fetchone()[0] - if args.version == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31): + if args.version == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32): raise ValueError("mobile V4 exige le schéma desktop v19 ou v20") - supported_versions = range(11, 32) + supported_versions = range(11, 33) legacy_v2_schema = args.version == 2 and schema_version == 10 if schema_version not in supported_versions and not legacy_v2_schema: - raise ValueError("schema desktop v11-v31 requis (v10 accepté pour export V2 explicite)") + raise ValueError("schema desktop v11-v32 requis (v10 accepté pour export V2 explicite)") if not complete_causal_envelope and schema_version >= 20 and con.execute( "SELECT 1 FROM sync_causal_state WHERE deleted=1 LIMIT 1").fetchone(): raise ValueError("causal protection refuses a mobile snapshot that omits tombstones") diff --git a/tools/export_programs.py b/tools/export_programs.py index fd770e7..c8144c9 100644 --- a/tools/export_programs.py +++ b/tools/export_programs.py @@ -16,8 +16,8 @@ def nullable(row, index): def export_programs(database: Path) -> dict: with closing(connect_database(database)) as db: - if db.execute("PRAGMA user_version").fetchone()[0] not in (26, 27, 28, 29, 30, 31): - raise ValueError("desktop schema v26, v27, v28 or v29 required") + if db.execute("PRAGMA user_version").fetchone()[0] not in (26, 27, 28, 29, 30, 31, 32): + raise ValueError("desktop schema v26 through v32 required") programs = [] for row in db.execute( "SELECT program_id,revision_id,title,note,state,start_date,end_date,created_at," diff --git a/tools/export_session_preparations.py b/tools/export_session_preparations.py index 2b0a855..8e345cb 100644 --- a/tools/export_session_preparations.py +++ b/tools/export_session_preparations.py @@ -17,9 +17,9 @@ MAX_WITHDRAWALS = 128 def build_export(connection: sqlite3.Connection) -> dict: - supported_versions = (24, 25, 26, 27, 28, 29, 30, 31) + supported_versions = (24, 25, 26, 27, 28, 29, 30, 31, 32) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - raise ValueError("desktop schema v24, v25, v26, v27, v28 or v29 required") + raise ValueError("desktop schema v24, v25, v26 through v32 required") deliveries = [] rows = connection.execute( "SELECT d.delivery_id,d.preparation_id,d.revision_id,d.execution_session_id," diff --git a/tools/export_training_feedback.py b/tools/export_training_feedback.py index ecc9dc5..ac0adc9 100644 --- a/tools/export_training_feedback.py +++ b/tools/export_training_feedback.py @@ -10,9 +10,9 @@ def main(complete_causal_envelope=False): parser=argparse.ArgumentParser(); parser.add_argument("output",type=Path); parser.add_argument("--database",type=Path,required=True); args=parser.parse_args() db=connect_database(args.database) try: - supported_versions = range(15, 32) + supported_versions = range(15, 33) if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - raise ValueError("schema desktop v15-v31 requis") + raise ValueError("schema desktop v15-v32 requis") if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0] >= 20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone(): raise ValueError("causal feedback protection requires the staged artifact") roots=db.execute("SELECT f.feedback_id,s.session_id,se.entry_id,e.exercise_id,f.observed_at FROM exercise_feedback f JOIN session_exercises se ON se.id=f.session_exercise_row_id JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id").fetchall() followups=db.execute("SELECT f.followup_id,s.session_id,f.observed_at FROM session_followups f JOIN sessions s ON s.id=f.session_row_id").fetchall() diff --git a/tools/heart_rate_exchange.py b/tools/heart_rate_exchange.py index 8913c64..64d7c48 100644 --- a/tools/heart_rate_exchange.py +++ b/tools/heart_rate_exchange.py @@ -236,8 +236,8 @@ def main() -> None: args = parser.parse_args() root = load(args.input) with connect_database(args.database) as db: - if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31): - fail("desktop schema v30-v31 required") + if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32): + fail("desktop schema v30-v32 required") db.execute("BEGIN IMMEDIATE") apply(db, root) db.commit() diff --git a/tools/import_ai_session_draft.py b/tools/import_ai_session_draft.py index 51393d8..af7ee40 100644 --- a/tools/import_ai_session_draft.py +++ b/tools/import_ai_session_draft.py @@ -193,9 +193,9 @@ def import_payload(connection, payload, imported_at=None): INVARIANT: validation and every child insert share one IMMEDIATE transaction; any failure leaves no draft, child, or import-identity row behind. """ - supported_versions = range(18, 32) + supported_versions = range(18, 33) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - raise ImportFailure("schema desktop v18-v31 requis") + raise ImportFailure("schema desktop v18-v32 requis") draft, entries = validate(payload, connection) digest = canonical_digest(payload) imported_at = imported_at or dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") diff --git a/tools/import_equipment_associations.py b/tools/import_equipment_associations.py index 9184a72..08534cf 100644 --- a/tools/import_equipment_associations.py +++ b/tools/import_equipment_associations.py @@ -206,9 +206,9 @@ def main(): payload = json.loads(args.artifact.read_text(encoding="utf-8")) connection = connect_database(args.database) try: - supported_versions = range(8, 32) + supported_versions = range(8, 33) if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions: - fail("schema desktop v8 à v31 requis") + fail("schema desktop v8 à v32 requis") proof_path = args.mobile_export if proof_path is None: candidate = args.artifact.with_name("trainlog-mobile-export-v2.json") diff --git a/tools/import_equipment_definitions.py b/tools/import_equipment_definitions.py index 93947c2..4369a17 100644 --- a/tools/import_equipment_definitions.py +++ b/tools/import_equipment_definitions.py @@ -53,9 +53,9 @@ def validate(payload, reserved): def apply_definitions(connection, definitions, complete_causal_envelope=False): """Apply validated definitions without owning the surrounding transaction.""" - supported_versions = range(8, 32) + supported_versions = range(8, 33) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - fail("schema desktop v8 à v31 requis") + fail("schema desktop v8 à v32 requis") if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone(): fail("causal equipment protection requires the staged artifact") imported = skipped = 0 diff --git a/tools/import_exercise_aliases.py b/tools/import_exercise_aliases.py index 912fee8..ffa6f63 100644 --- a/tools/import_exercise_aliases.py +++ b/tools/import_exercise_aliases.py @@ -56,9 +56,9 @@ def load(path): def apply_aliases(con, aliases): """Apply parsed aliases inside the caller-owned transaction.""" - supported_versions = range(12, 32) + supported_versions = range(12, 33) if con.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - fail("schema desktop v12-v31 requis") + fail("schema desktop v12-v32 requis") for source, canonical in aliases: target = con.execute("SELECT id,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?", (canonical,)).fetchone() if target is None: fail(f"cible canonique absente: {canonical}") diff --git a/tools/import_exercise_body_zones.py b/tools/import_exercise_body_zones.py index 738c772..9f43e6e 100644 --- a/tools/import_exercise_body_zones.py +++ b/tools/import_exercise_body_zones.py @@ -151,9 +151,9 @@ def replace(connection, row_id, primary, secondary): def apply_body_zones(connection, parsed, mobile_proof, complete_causal_envelope=False): """Apply validated zone rows without owning transaction boundaries.""" - supported_versions = range(11, 32) + supported_versions = range(11, 33) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - raise ImportFailure("schema desktop v11-v31 requis") + raise ImportFailure("schema desktop v11-v32 requis") if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0]>=20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone(): raise ImportFailure("causal BODY ZONES protection requires the staged artifact") connection.execute("PRAGMA foreign_keys=ON");grouped={} # CONTRACT: resolve complete alias groups before the first write; direct diff --git a/tools/import_exercise_profile_state.py b/tools/import_exercise_profile_state.py index ca19d35..bfcf5e9 100644 --- a/tools/import_exercise_profile_state.py +++ b/tools/import_exercise_profile_state.py @@ -50,8 +50,8 @@ def apply_profile_state(con, root, allow_pending=False): if not isinstance(root,dict) or set(root)!={"format","version","generated_at","exercises"} or root["format"]!="trainlog-exercise-profile-state" or type(root["version"]) is not int or root["version"]!=1 or not isinstance(root["exercises"],list): fail("enveloppe profile-state invalide") parse_timestamp(root["generated_at"], "generated_at");con.row_factory=sqlite3.Row if con.execute("PRAGMA user_version").fetchone()[0] not in ( - 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31): - fail("schema desktop v17-v31 requis") + 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32): + fail("schema desktop v17-v32 requis") applied=equal=ancestor=pending=0;seen=set() for item in root["exercises"]: validate(item);eid=item["exercise_id"] diff --git a/tools/import_mobile_export.py b/tools/import_mobile_export.py index 67cca7e..967360b 100755 --- a/tools/import_mobile_export.py +++ b/tools/import_mobile_export.py @@ -847,9 +847,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 range(5, 32): + if version not in range(5, 33): raise ImportFailure( - f"base desktop schema v5 à v31 attendue, version trouvée: {version}" + f"base desktop schema v5 à v32 attendue, version trouvée: {version}" ) @@ -2002,8 +2002,8 @@ def apply_payload(connection, payload, trace_exercises=False, complete_causal_en require_supported_schema(connection) schema_version = connection.execute("PRAGMA user_version;").fetchone()[0] - if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31): - raise ImportFailure("mobile V4 exige le schéma desktop v19 à v31") + if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32): + raise ImportFailure("mobile V4 exige le schéma desktop v19 à v32") if schema_version >= 20: protected = {(row[0], row[1]) for row in connection.execute( "SELECT target_kind,target_id FROM sync_causal_state WHERE deleted=1")} diff --git a/tools/import_training_feedback.py b/tools/import_training_feedback.py index ffadc20..58bd410 100644 --- a/tools/import_training_feedback.py +++ b/tools/import_training_feedback.py @@ -52,9 +52,9 @@ def merge_revisions(db,table,parent_column,parent_id,revisions): def apply_feedback(db,root,complete_causal_envelope=False): roots_added=roots_skipped=revisions_added=revisions_skipped=0 db.execute("PRAGMA foreign_keys=ON") - supported_versions = range(15, 32) + supported_versions = range(15, 33) if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - fail("schema desktop v15-v31 requis") + fail("schema desktop v15-v32 requis") if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0]>=20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone():fail("causal feedback protection requires the staged artifact") seen=set() for index,item in enumerate(root["exercise_feedback"]): diff --git a/tools/package_sync_candidate.py b/tools/package_sync_candidate.py index efb4f4e..a432cc0 100644 --- a/tools/package_sync_candidate.py +++ b/tools/package_sync_candidate.py @@ -18,7 +18,8 @@ RUNTIME_TOOLS = [ "sync_drive_transport.py", "sync_generation_exchange.py", "sleep_diary_exchange.py", - "heart_rate_exchange.py", + "cardio_session_exchange.py", + "heart_rate_exchange.py", "session_timeline_exchange.py", "trainlog_sqlite.py", "validate_json.py", @@ -116,8 +117,8 @@ def main() -> int: ["git", "rev-parse", "HEAD"], cwd=ROOT, text=True ).strip(), "product_version": "0.1.5", - "desktop_schema": 31, - "android_schema": 28, + "desktop_schema": 32, + "android_schema": 29, "protocols": [ "mobile-export-v3", "mobile-history-v4", @@ -132,6 +133,7 @@ def main() -> int: "programs-v1", "program-executions-v1", "sleep-diary-v1", + "cardio-sessions-v1", "heart-rate-v1", "session-timeline-v1", ], diff --git a/tools/program_execution_exchange.py b/tools/program_execution_exchange.py index ac49e45..1ac2916 100644 --- a/tools/program_execution_exchange.py +++ b/tools/program_execution_exchange.py @@ -87,8 +87,8 @@ def load(path: Path) -> dict: def apply_executions(db: sqlite3.Connection, root: dict) -> tuple[int, int, int]: - if db.execute("PRAGMA user_version").fetchone()[0] not in (27, 28, 29, 30, 31): - fail("desktop schema v27, v28 or v29 required") + if db.execute("PRAGMA user_version").fetchone()[0] not in (27, 28, 29, 30, 31, 32): + fail("desktop schema v27 through v32 required") added = 0 advanced = 0 skipped = 0 diff --git a/tools/session_timeline_exchange.py b/tools/session_timeline_exchange.py index 8dbe480..2eac26e 100644 --- a/tools/session_timeline_exchange.py +++ b/tools/session_timeline_exchange.py @@ -184,8 +184,8 @@ def main() -> None: args = parser.parse_args() root = load(args.input) with connect_database(args.database) as db: - if db.execute("PRAGMA user_version").fetchone()[0] != 31: - fail("desktop schema v31 required") + if db.execute("PRAGMA user_version").fetchone()[0] not in (31, 32): + fail("desktop schema v31-v32 required") db.execute("BEGIN IMMEDIATE") apply(db, root) db.commit() diff --git a/tools/sleep_diary_exchange.py b/tools/sleep_diary_exchange.py index 78262b8..4df5668 100644 --- a/tools/sleep_diary_exchange.py +++ b/tools/sleep_diary_exchange.py @@ -422,8 +422,8 @@ def main() -> None: import_command.add_argument("--database", required=True, type=Path) args = parser.parse_args() with connect_database(args.database) as db: - if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31): - fail("desktop schema v30-v31 required") + if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32): + fail("desktop schema v30-v32 required") if args.command == "export": args.output.write_text(json.dumps(build(db), ensure_ascii=False, sort_keys=True, separators=(",", ":")), encoding="utf-8") diff --git a/tools/sync_generation_exchange.py b/tools/sync_generation_exchange.py index bdef365..d880e68 100644 --- a/tools/sync_generation_exchange.py +++ b/tools/sync_generation_exchange.py @@ -20,6 +20,7 @@ from pathlib import Path, PurePosixPath from trainlog_sqlite import connect_database import causal_delete_exchange +import cardio_session_exchange import execution_draft_exchange import heart_rate_exchange import session_timeline_exchange @@ -83,6 +84,7 @@ ARTIFACTS = ( ) SUPPORTED = {(row[1], row[2]) for row in ARTIFACTS} | { ("trainlog-program-executions", 1), + ("trainlog-cardio-sessions", 1), ("trainlog-heart-rate", 1), ("trainlog-session-timeline", 1), } @@ -213,9 +215,9 @@ def validate_manifest_bytes(raw: bytes, expected_consumer: str | None = None): def require_schema(db: sqlite3.Connection) -> None: - supported_versions = (24, 25, 26, 27, 28, 29, 30, 31) + supported_versions = (24, 25, 26, 27, 28, 29, 30, 31, 32) if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - raise GenerationError("desktop schema v24, v25, v26, v27, v28 or v29 required") + raise GenerationError("desktop schema v24, v25, v26 through v32 required") def peer_identity(db: sqlite3.Connection, kind: str) -> str: @@ -665,6 +667,12 @@ def consume_desktop(database: Path, directory: Path) -> dict: history = import_mobile_export.load_payload(required("history")) known_equipment = set(reserved) | {item[0] for item in definitions} import_mobile_export.validate_payload(history, known_equipment) + cardio_sessions = ( + cardio_session_exchange.load(listed["cardio-sessions"]) + if "cardio-sessions" in listed else None + ) + if cardio_sessions is not None: + cardio_session_exchange.validate(cardio_sessions, known_equipment) association_payload = strict_json(required("equipment-associations").read_bytes(), MAX_ARTIFACT) zones_payload = strict_json(required("body-zones").read_bytes(), MAX_ARTIFACT) parsed_zones = import_exercise_body_zones.parse_payload(zones_payload) @@ -704,6 +712,12 @@ def consume_desktop(database: Path, directory: Path) -> dict: import_exercise_profile_state.apply_profile_state(db, profile, allow_pending=True) import_equipment_definitions.apply_definitions(db, definitions, complete_causal_envelope=True) import_mobile_export.apply_payload(db, history, complete_causal_envelope=True) + if cardio_sessions is not None: + cardio_session_exchange.apply( + db, + cardio_sessions, + complete_causal_envelope=True, + ) import_exercise_profile_state.apply_profile_state(db, profile) import_exercise_aliases.apply_aliases(db, aliases) import_equipment_associations.apply_associations( diff --git a/tui/include/trainlog/database.h b/tui/include/trainlog/database.h index 3520719..e56251c 100644 --- a/tui/include/trainlog/database.h +++ b/tui/include/trainlog/database.h @@ -17,7 +17,7 @@ #include "trainlog/model.h" #include "trainlog/status.h" -#define TRAINLOG_DATABASE_SCHEMA_VERSION 31 +#define TRAINLOG_DATABASE_SCHEMA_VERSION 32 typedef struct TrainlogDatabase TrainlogDatabase; diff --git a/tui/include/trainlog/model.h b/tui/include/trainlog/model.h index c0f5f05..2066352 100644 --- a/tui/include/trainlog/model.h +++ b/tui/include/trainlog/model.h @@ -47,7 +47,8 @@ typedef enum TrainlogLoadMode { typedef enum TrainlogSessionType { TRAINLOG_SESSION_TRAINING = 0, - TRAINLOG_SESSION_MAX_TEST + TRAINLOG_SESSION_MAX_TEST, + TRAINLOG_SESSION_CARDIO } TrainlogSessionType; typedef struct TrainlogExercise { diff --git a/tui/meson.build b/tui/meson.build index 218828d..361f7f2 100644 --- a/tui/meson.build +++ b/tui/meson.build @@ -839,6 +839,14 @@ test_schema_v31_migration = executable( ) test('schema_v31_migration', test_schema_v31_migration) +test_schema_v32_migration = executable( + 'test_schema_v32_migration', + 'tests/test_schema_v32_migration.c', + dependencies: trainlog_core_dep, + c_args: strict_c_args, +) +test('schema_v32_migration', test_schema_v32_migration) + test_schema_v7_migration = executable( 'test_schema_v7_migration', 'tests/test_schema_v7_migration.c', @@ -928,6 +936,11 @@ test( find_program('python3'), args: [meson.project_source_root() / 'tests/test_session_timeline_exchange.py'], ) +test( + 'cardio_session_exchange', + find_program('python3'), + args: [meson.project_source_root() / 'tests/test_cardio_session_exchange.py'], +) test( 'session_preparation_export', find_program('python3'), diff --git a/tui/src/database.c b/tui/src/database.c index 229f40c..9051bff 100644 --- a/tui/src/database.c +++ b/tui/src/database.c @@ -1393,6 +1393,49 @@ static const char *const MIGRATE_V30_TO_V31_SQL = "session_exercise_timeline(started_at,session_id,entry_id);" "PRAGMA user_version=31;COMMIT;"; +/* WHY: legacy session_type is a frozen training/max_test compatibility column. + * CONTRACT: v32 adds canonical local session_kind and backfills it exactly + * from legacy history. INVARIANT: no legacy codec or MAX classification is + * widened to cardio. */ +static const char *const MIGRATE_V31_TO_V32_SQL = + "BEGIN IMMEDIATE;" + "PRAGMA user_version=32;COMMIT;"; + +static TrainlogStatus ensure_v32_session_kind(TrainlogDatabase *database) { + sqlite3_stmt *statement = NULL; + bool found_kind = false; + bool found_legacy_type = false; + int result = sqlite3_prepare_v2( + database->connection, "PRAGMA table_info(sessions);", -1, &statement, NULL); + if (result != SQLITE_OK) { + return TRAINLOG_STATUS_DATABASE_ERROR; + } + while ((result = sqlite3_step(statement)) == SQLITE_ROW) { + const char *name = (const char *)sqlite3_column_text(statement, 1); + if (name != NULL && strcmp(name, "session_kind") == 0) { + found_kind = true; + } else if (name != NULL && strcmp(name, "session_type") == 0) { + found_legacy_type = true; + } + } + if (result != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) { + return TRAINLOG_STATUS_DATABASE_ERROR; + } + if (!found_kind && + execute_sql(database, + "ALTER TABLE sessions ADD COLUMN session_kind TEXT NOT NULL " + "DEFAULT 'training' CHECK(session_kind IN('training','max_test','cardio'));") != + TRAINLOG_STATUS_OK) { + return TRAINLOG_STATUS_DATABASE_ERROR; + } + if (found_legacy_type && + execute_sql(database, "UPDATE sessions SET session_kind=session_type;") != + TRAINLOG_STATUS_OK) { + return TRAINLOG_STATUS_DATABASE_ERROR; + } + return TRAINLOG_STATUS_OK; +} + /* WHY: schema v29 was already opened on the private 0.1.4 review installation * before medication capture joined the same unreleased migration. CONTRACT: * this additive repair is identical to the tail of v28 -> v29 and runs only @@ -2227,7 +2270,7 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database, version == 16 || version == 17 || version == 18 || version == 19 || version == 20 || version == 21 || version == 22 || version == 23 || version == 24 || version == 25 || version == 26 || version == 27 || version == 28 || version == 29 || version == 30 || - version == 31) { + version == 31 || version == 32) { status = TRAINLOG_STATUS_OK; } else { if (version == 1) { @@ -2381,6 +2424,12 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database, if (status == TRAINLOG_STATUS_OK && version < 31) { status = execute_sql(database, MIGRATE_V30_TO_V31_SQL); } + if (status == TRAINLOG_STATUS_OK && version < 32) { + status = ensure_v32_session_kind(database); + } + if (status == TRAINLOG_STATUS_OK && version < 32) { + status = execute_sql(database, MIGRATE_V31_TO_V32_SQL); + } if (status == TRAINLOG_STATUS_OK) { status = ensure_v18_ai_draft_publication_state(database); } @@ -2400,7 +2449,7 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database, if (status != TRAINLOG_STATUS_OK) { set_open_diagnostic(output_diagnostic, output_diagnostic_capacity, - version == 0 ? "create schema v31" : "migrate database to schema v31", + version == 0 ? "create schema v32" : "migrate database to schema v32", database->connection, SQLITE_ERROR); (void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL); @@ -2903,6 +2952,9 @@ static const char *session_type_to_sql(TrainlogSessionType type) { case TRAINLOG_SESSION_MAX_TEST: return "max_test"; + case TRAINLOG_SESSION_CARDIO: + return "cardio"; + default: return NULL; } @@ -3135,6 +3187,11 @@ static bool session_type_from_sql(const char *text, TrainlogSessionType *output) return true; } + if (strcmp(text, "cardio") == 0) { + *output = TRAINLOG_SESSION_CARDIO; + return true; + } + return false; } @@ -3912,16 +3969,19 @@ static TrainlogStatus insert_session_header(TrainlogDatabase *database, const TrainlogSessionInput *session, sqlite3_int64 *output_row_id) { static const char *const SQL = "INSERT INTO sessions(" - "session_id, started_at, ended_at, session_type, notes" - ") VALUES(?1, ?2, ?3, ?4, ?5);"; + "session_id, started_at, ended_at, session_type, session_kind, notes" + ") VALUES(?1, ?2, ?3, ?4, ?5, ?6);"; sqlite3_stmt *statement = NULL; - const char *session_type; + const char *session_kind; + const char *legacy_session_type; int rc; - session_type = session_type_to_sql(session->session_type); + session_kind = session_type_to_sql(session->session_type); + legacy_session_type = + session->session_type == TRAINLOG_SESSION_CARDIO ? "training" : session_kind; - if (session_type == NULL) { + if (session_kind == NULL || legacy_session_type == NULL) { return TRAINLOG_STATUS_INVALID_ARGUMENT; } @@ -3943,13 +4003,17 @@ static TrainlogStatus insert_session_header(TrainlogDatabase *database, } if (rc == SQLITE_OK) { - rc = sqlite3_bind_text(statement, 4, session_type, -1, SQLITE_STATIC); + rc = sqlite3_bind_text(statement, 4, legacy_session_type, -1, SQLITE_STATIC); + } + + if (rc == SQLITE_OK) { + rc = sqlite3_bind_text(statement, 5, session_kind, -1, SQLITE_STATIC); } if (rc == SQLITE_OK) { rc = session->notes != NULL && session->notes[0] != '\0' - ? sqlite3_bind_text(statement, 5, session->notes, -1, SQLITE_TRANSIENT) - : sqlite3_bind_null(statement, 5); + ? sqlite3_bind_text(statement, 6, session->notes, -1, SQLITE_TRANSIENT) + : sqlite3_bind_null(statement, 6); } if (rc != SQLITE_OK) { @@ -4524,7 +4588,7 @@ trainlog_database_replace_session_exercises(TrainlogDatabase *database, } rc = sqlite3_prepare_v2(database->connection, - "SELECT session_type FROM sessions WHERE id=?1;", + "SELECT session_kind FROM sessions WHERE id=?1;", -1, &statement, NULL); @@ -4736,7 +4800,7 @@ TrainlogStatus trainlog_database_list_sessions(TrainlogDatabase *database, "s.session_id, " "s.started_at, " "COALESCE(s.ended_at, ''), " - "s.session_type, " + "s.session_kind, " "COUNT(se.id) " "FROM sessions AS s " "LEFT JOIN session_exercises AS se " @@ -5254,7 +5318,7 @@ trainlog_database_get_session_details(TrainlogDatabase *database, TrainlogPersistedExerciseDetail *output_exercises, size_t exercise_capacity, size_t *output_exercise_count) { - static const char *const HEADER_SQL = "SELECT started_at, COALESCE(ended_at, ''), session_type " + static const char *const HEADER_SQL = "SELECT started_at, COALESCE(ended_at, ''), session_kind " "FROM sessions " "WHERE session_id = ?1;"; @@ -5781,7 +5845,7 @@ TrainlogStatus trainlog_database_list_exercise_performance(TrainlogDatabase *dat static const char *const SQL = "SELECT " "s.session_id, " "s.started_at, " - "s.session_type, " + "s.session_kind, " "se.tracking_mode, " "se.load_mode, " "ps.id, " diff --git a/tui/tests/test_schema_v29_migration.c b/tui/tests/test_schema_v29_migration.c index 4007fab..43284b5 100644 --- a/tui/tests/test_schema_v29_migration.c +++ b/tui/tests/test_schema_v29_migration.c @@ -53,7 +53,7 @@ int main(void) { trainlog_database_close(production); production = NULL; CHECK(sqlite3_open(path, &raw) == SQLITE_OK); - CHECK(scalar(raw, "PRAGMA user_version") == 31); + CHECK(scalar(raw, "PRAGMA user_version") == 32); CHECK(scalar(raw, "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN " "('sleep_diary_entries','sleep_diary_revisions','sleep_diary_events'," @@ -97,7 +97,7 @@ int main(void) { trainlog_database_close(production); production = NULL; CHECK(sqlite3_open(path, &raw) == SQLITE_OK); - CHECK(scalar(raw, "PRAGMA user_version") == 31); + CHECK(scalar(raw, "PRAGMA user_version") == 32); CHECK(scalar(raw, "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name LIKE " "'sleep_medication%'") == 3); diff --git a/tui/tests/test_schema_v30_migration.c b/tui/tests/test_schema_v30_migration.c index 9efde64..c90d21c 100644 --- a/tui/tests/test_schema_v30_migration.c +++ b/tui/tests/test_schema_v30_migration.c @@ -52,7 +52,7 @@ int main(void) { CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK); CHECK(trainlog_database_schema_version(production, &version) == TRAINLOG_STATUS_OK); - CHECK(version == 31); + CHECK(version == 32); trainlog_database_close(production); production = NULL; diff --git a/tui/tests/test_schema_v31_migration.c b/tui/tests/test_schema_v31_migration.c index 07db381..b3dcf8c 100644 --- a/tui/tests/test_schema_v31_migration.c +++ b/tui/tests/test_schema_v31_migration.c @@ -49,7 +49,7 @@ int main(void) { CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK); CHECK(trainlog_database_schema_version(production, &version) == TRAINLOG_STATUS_OK); - CHECK(version == 31); + CHECK(version == 32); trainlog_database_close(production); production = NULL; diff --git a/tui/tests/test_schema_v32_migration.c b/tui/tests/test_schema_v32_migration.c new file mode 100644 index 0000000..7e77a4e --- /dev/null +++ b/tui/tests/test_schema_v32_migration.c @@ -0,0 +1,73 @@ +#define _POSIX_C_SOURCE 200809L + +#include +#include +#include +#include +#include + +#include "trainlog/database.h" + +#define CHECK(value) do { if (!(value)) { fprintf(stderr, "CHECK failed line %d: %s\n", __LINE__, #value); goto cleanup; } } while (0) + +static int scalar(sqlite3 *database, const char *sql) { + sqlite3_stmt *statement = NULL; + int value = -1; + if (sqlite3_prepare_v2(database, sql, -1, &statement, NULL) == SQLITE_OK && + sqlite3_step(statement) == SQLITE_ROW) { + value = sqlite3_column_int(statement, 0); + } + (void)sqlite3_finalize(statement); + return value; +} + +int main(void) { + char path[] = "/tmp/trainlog-schema-v32-XXXXXX"; + TrainlogDatabase *production = NULL; + sqlite3 *raw = NULL; + int descriptor = mkstemp(path); + int version = 0; + int result = EXIT_FAILURE; + + CHECK(descriptor >= 0 && close(descriptor) == 0); + CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK); + trainlog_database_close(production); + production = NULL; + + CHECK(sqlite3_open(path, &raw) == SQLITE_OK); + CHECK(sqlite3_exec(raw, + "ALTER TABLE sessions DROP COLUMN session_kind;" + "PRAGMA user_version=31;", + NULL, NULL, NULL) == SQLITE_OK); + CHECK(sqlite3_close(raw) == SQLITE_OK); + raw = NULL; + + CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_schema_version(production, &version) == TRAINLOG_STATUS_OK); + CHECK(version == 32); + trainlog_database_close(production); + production = NULL; + + CHECK(sqlite3_open(path, &raw) == SQLITE_OK); + CHECK(scalar(raw, + "SELECT COUNT(*) FROM pragma_table_info('sessions') " + "WHERE name='session_kind'") == 1); + CHECK(sqlite3_exec(raw, + "INSERT INTO sessions(session_id,started_at,ended_at,session_type,session_kind)" + "VALUES('se_11111111-1111-4111-8111-111111111111'," + "'2026-09-23T09:00:00+02:00','2026-09-23T09:30:00+02:00'," + "'training','cardio');", + NULL, NULL, NULL) == SQLITE_OK); + CHECK(scalar(raw, + "SELECT COUNT(*) FROM sessions WHERE session_type='training' " + "AND session_kind='cardio'") == 1); + CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_foreign_key_check") == 0); + result = EXIT_SUCCESS; + +cleanup: + if (production != NULL) trainlog_database_close(production); + if (raw != NULL) (void)sqlite3_close(raw); + (void)unlink(path); + if (result == EXIT_SUCCESS) puts("PASS schema v31 to v32 cardio session kind migration"); + return result; +} diff --git a/tui/tests/test_schema_v7_migration.c b/tui/tests/test_schema_v7_migration.c index 3303657..8fbdc31 100644 --- a/tui/tests/test_schema_v7_migration.c +++ b/tui/tests/test_schema_v7_migration.c @@ -178,8 +178,8 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void) { TRAINLOG_STATUS_DATABASE_ERROR); CHECK(database == NULL); CHECK(strncmp(diagnostic, - "migrate database to schema v31: SQLite rc=", - strlen("migrate database to schema v31: SQLite rc=")) == 0); + "migrate database to schema v32: SQLite rc=", + strlen("migrate database to schema v32: SQLite rc=")) == 0); CHECK(strstr(diagnostic, "extended_rc=") != NULL); CHECK(strstr(diagnostic, "custom_equipment") != NULL); CHECK(strstr(diagnostic, "already exists") != NULL); @@ -197,12 +197,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=32;", NULL, NULL, NULL) == SQLITE_OK); + CHECK(sqlite3_exec(raw, "PRAGMA user_version=33;", 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 32 is newer") != NULL); + CHECK(strstr(diagnostic, "schema version 33 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 590bfa4..c216126 100644 --- a/tui/tests/test_schema_v9_migration.c +++ b/tui/tests/test_schema_v9_migration.c @@ -154,7 +154,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 v31") != NULL); + CHECK(strstr(diagnostic, "migrate database to schema v32") != NULL); CHECK(strstr(diagnostic, "performed_sets_v9") != NULL); CHECK(sqlite3_open(path, &raw) == SQLITE_OK);