From dd39c85f9499cfeffbca878ff3c079505aa56fe8 Mon Sep 17 00:00:00 2001 From: fy59 Date: Wed, 23 Sep 2026 11:32:15 +0200 Subject: [PATCH] feat(training): timestamp exercise execution --- CHANGELOG.md | 7 + .../trainlog/data/AndroidBackupService.kt | 4 +- .../trainlog/data/HeartRateSensorService.kt | 12 + .../trainlog/data/SyncGenerationService.kt | 22 + .../trainlog/data/TrainlogRepository.kt | 494 +++++++++++++++++- .../trainlog/model/SessionModels.kt | 18 + .../labfytools/trainlog/ui/SessionScreen.kt | 277 +++++++++- .../app/src/main/res/values-en/strings.xml | 11 + android/app/src/main/res/values/strings.xml | 11 + .../data/AndroidV10PlanningMigrationTest.kt | 2 +- .../AndroidV11ExerciseAliasMigrationTest.kt | 2 +- .../AndroidV16AiSessionDraftMigrationTest.kt | 2 +- ...AndroidV25ProgramExecutionMigrationTest.kt | 2 +- .../data/AndroidV26SleepMigrationTest.kt | 2 +- .../data/AndroidV27HeartRateMigrationTest.kt | 2 +- .../AndroidV28SessionTimelineMigrationTest.kt | 105 ++++ .../labfytools/trainlog/data/BodyZonesTest.kt | 2 +- .../data/BridgeBackupMigrationTest.kt | 2 +- .../data/SessionPreparationExchangeTest.kt | 2 +- .../data/SessionTimelineRepositoryTest.kt | 181 +++++++ .../data/SessionTimelineSyncGenerationTest.kt | 209 ++++++++ .../data/SyncGenerationServiceTest.kt | 2 +- .../data/TrainlogRepositoryDraftTest.kt | 4 +- docs/current_state.md | 7 +- docs/database.md | 14 + docs/roadmap.md | 2 +- docs/sync_exchange.md | 18 +- docs/tests.md | 17 + tests/test_session_timeline_exchange.py | 138 +++++ tests/test_sync_deployment_tools.py | 5 +- 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 | 2 +- tools/export_session_preparations.py | 2 +- 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 | 6 +- tools/program_execution_exchange.py | 2 +- tools/session_timeline_exchange.py | 195 +++++++ tools/sleep_diary_exchange.py | 4 +- tools/sync_generation_exchange.py | 8 +- tui/include/trainlog/database.h | 2 +- tui/meson.build | 13 + tui/src/database.c | 24 +- tui/tests/test_schema_v29_migration.c | 15 +- tui/tests/test_schema_v30_migration.c | 3 +- tui/tests/test_schema_v31_migration.c | 72 +++ tui/tests/test_schema_v7_migration.c | 8 +- tui/tests/test_schema_v9_migration.c | 2 +- 66 files changed, 1916 insertions(+), 104 deletions(-) create mode 100644 android/app/src/test/java/com/labfytools/trainlog/data/AndroidV28SessionTimelineMigrationTest.kt create mode 100644 android/app/src/test/java/com/labfytools/trainlog/data/SessionTimelineRepositoryTest.kt create mode 100644 android/app/src/test/java/com/labfytools/trainlog/data/SessionTimelineSyncGenerationTest.kt create mode 100644 tests/test_session_timeline_exchange.py create mode 100644 tools/session_timeline_exchange.py create mode 100644 tui/tests/test_schema_v31_migration.c diff --git a/CHANGELOG.md b/CHANGELOG.md index 39deb25..840b2d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,13 @@ uses grey/orange/green for disconnected/reconnecting/fresh states. The real CYCPLUS H2 was validated through force-stop/restart and Bluetooth off/on recovery on the private 0.1.5 versionCode 40 installation. +- Added the training execution timeline used for cardio correlation. Android + schema v28 records explicit start/end actions for stable exercise occurrences + while preserving the session lifecycle `started_at`/`ended_at` as canonical + bounds. Only one exercise can be active; session finalization refuses an + unclosed occurrence. Live heart-rate samples snapshot the active `entry_id`, + rest intervals remain unowned, and the optional `trainlog-session-timeline` + V1 companion imports idempotently into desktop schema v31. Development opened after stable v0.1.4 release. 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 6f90301..03324d3 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..27 + manifest.getInt("schema") in 17..28 ) { "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..27) { "Unsupported Android schema ${db.version}." } + require(db.version in 17..28) { "Unsupported Android schema ${db.version}." } return db.version } finally { db.close() diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/HeartRateSensorService.kt b/android/app/src/main/java/com/labfytools/trainlog/data/HeartRateSensorService.kt index 99c6099..12b26e1 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/HeartRateSensorService.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/HeartRateSensorService.kt @@ -23,6 +23,7 @@ import android.os.SystemClock import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat import com.labfytools.trainlog.R +import java.time.OffsetDateTime class HeartRateSensorService : Service() { private val main = Handler(Looper.getMainLooper()) @@ -36,6 +37,10 @@ class HeartRateSensorService : Service() { private var reconnectAttempt = 0 private var lastMeasurementElapsed = 0L private var lastNotificationBpm: Int? = null + private var repository: TrainlogRepository? = null + + private fun repository(): TrainlogRepository = + repository ?: TrainlogRepository(applicationContext).also { repository = it } private val reconnectRunnable = Runnable { connectSelected() } private val connectionTimeoutRunnable = @@ -272,6 +277,11 @@ class HeartRateSensorService : Service() { if (parsed !is HeartRateMeasurementParseResult.Parsed) return lastMeasurementElapsed = SystemClock.elapsedRealtime() HeartRateLiveState.measurement(parsed.measurement) + repository().recordLiveHeartRateForActiveSession( + sensorName = selected?.name, + observedAt = OffsetDateTime.now().toString(), + measurement = parsed.measurement, + ) if (lastNotificationBpm != parsed.measurement.bpm) { lastNotificationBpm = parsed.measurement.bpm updateForegroundNotification() @@ -314,6 +324,8 @@ class HeartRateSensorService : Service() { main.removeCallbacks(connectionTimeoutRunnable) main.removeCallbacks(staleRunnable) closeGatt() + repository?.close() + repository = null HeartRateLiveState.disconnected() super.onDestroy() } 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 2489f76..f60025e 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationService.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationService.kt @@ -70,6 +70,8 @@ internal class SyncGenerationService(private val repository: TrainlogRepository) Kind("trainlog-program-executions", 1, "program-executions-v1.json", false), "sleep-diary" to Kind("trainlog-sleep-diary", 1, "sleep-diary-v1.json", false), "heart-rate" to Kind("trainlog-heart-rate", 1, "heart-rate-v1.json", false), + "session-timeline" to + Kind("trainlog-session-timeline", 1, "session-timeline-v1.json", false), ) private val SUPPORTED = (ANDROID_KINDS.values + @@ -431,6 +433,18 @@ internal class SyncGenerationService(private val repository: TrainlogRepository) arrayOf(generationId, captures.getJSONObject(index).getString("capture_id")), ) } + val timeline = JSONObject(checkNotNull(artifacts["session-timeline"])) + val timelineSessions = timeline.getJSONArray("sessions") + for (index in 0 until timelineSessions.length()) { + db.execSQL( + "INSERT INTO session_timeline_generation_sessions(generation_id,session_id) " + + "VALUES(?,?)", + arrayOf( + generationId, + timelineSessions.getJSONObject(index).getString("session_id"), + ), + ) + } } return CapturedSyncGeneration(generationId, runId, digest, stage) } catch (error: Exception) { @@ -1076,6 +1090,14 @@ internal class SyncGenerationService(private val repository: TrainlogRepository) "m.capture_id=heart_rate_captures.capture_id)", arrayOf(ack.getString("consumed_at"), ack.getString("generation_id")), ) + db.execSQL( + "UPDATE session_timeline_sessions SET acknowledged_at=? WHERE " + + "acknowledged_at IS NULL AND EXISTS(" + + "SELECT 1 FROM session_timeline_generation_sessions m " + + "WHERE m.generation_id=? AND " + + "m.session_id=session_timeline_sessions.session_id)", + arrayOf(ack.getString("consumed_at"), ack.getString("generation_id")), + ) } target } 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 e4ddaab..0b03991 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt @@ -14,6 +14,7 @@ import android.database.sqlite.SQLiteOpenHelper import android.util.JsonReader import android.util.JsonToken import com.labfytools.trainlog.model.ActiveSessionDraft +import com.labfytools.trainlog.model.ActiveSessionTimelineContext import com.labfytools.trainlog.model.AiSessionDraft import com.labfytools.trainlog.model.BodyObservationDraft import com.labfytools.trainlog.model.BodyObservationSummary @@ -37,6 +38,7 @@ import com.labfytools.trainlog.model.SessionSummary import com.labfytools.trainlog.model.SessionDetail import com.labfytools.trainlog.model.SessionExerciseDetail import com.labfytools.trainlog.model.SessionExercisePlan +import com.labfytools.trainlog.model.SessionExerciseTiming import com.labfytools.trainlog.model.SessionFollowUp import com.labfytools.trainlog.model.SessionLoadMode import com.labfytools.trainlog.model.SessionSetDraft @@ -505,6 +507,14 @@ class TrainlogRepository( data object DatabaseError : HeartRateMutationResult } + sealed interface SessionExerciseTimingResult { + data class Started(val timing: SessionExerciseTiming) : SessionExerciseTimingResult + data class Finished(val timing: SessionExerciseTiming) : SessionExerciseTimingResult + data class ActiveConflict(val activeEntryId: String) : SessionExerciseTimingResult + data class Invalid(val message: String) : SessionExerciseTimingResult + data class DatabaseError(val message: String) : SessionExerciseTimingResult + } + private fun sleepId(prefix: String): String = "${prefix}_${UUID.randomUUID()}" private fun sleepIdentity(value: String, prefix: String): Boolean = @@ -1158,6 +1168,296 @@ class TrainlogRepository( ) } + private fun ensureSessionTimelineForPersistedDraft( + db: SQLiteDatabase, + draft: ActiveSessionDraft, + ): Pair { + val identity = + db.rawQuery( + "SELECT session_id,started_at FROM active_session_draft WHERE id=1", + null, + ).use { cursor -> + check(cursor.moveToFirst()) { "Active draft missing after persistence." } + check(!cursor.isNull(0) && !cursor.isNull(1)) { + "Active draft is missing lifecycle identity." + } + cursor.getString(0) to cursor.getString(1) + } + db.execSQL( + "INSERT OR IGNORE INTO session_timeline_sessions(" + + "session_id,started_at,ended_at,acknowledged_at) VALUES(?,?,NULL,NULL)", + arrayOf(identity.first, identity.second), + ) + val timelineStart = + db.rawQuery( + "SELECT started_at,ended_at FROM session_timeline_sessions WHERE session_id=?", + arrayOf(identity.first), + ).use { cursor -> + check(cursor.moveToFirst()) + check(cursor.isNull(1)) { "Active session timeline is already completed." } + cursor.getString(0) + } + check(timelineStart == identity.second) { + "Active session timeline start conflicts with draft lifecycle." + } + + val requested = draft.exercises.associate { it.entryId to it.exercise.exerciseId } + db.rawQuery( + "SELECT entry_id,exercise_id FROM session_timeline_exercises WHERE session_id=?", + arrayOf(identity.first), + ).use { cursor -> + while (cursor.moveToNext()) { + val entryId = cursor.getString(0) + check(requested[entryId] == cursor.getString(1)) { + "A started exercise cannot be removed or reassigned." + } + } + } + return identity + } + + fun activeSessionTimelineContext(): ActiveSessionTimelineContext? { + val db = database.readableDatabase + return db.rawQuery( + "SELECT d.session_id,d.started_at,d.session_type,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, + ).use { cursor -> + if (!cursor.moveToFirst() || cursor.isNull(0) || cursor.isNull(1)) null + else ActiveSessionTimelineContext( + sessionId = cursor.getString(0), + startedAt = cursor.getString(1), + sessionType = SessionType.fromWire(cursor.getString(2)), + activeEntryId = if (cursor.isNull(3)) null else cursor.getString(3), + ) + } + } + + fun listActiveSessionExerciseTimings(): List { + val context = activeSessionTimelineContext() ?: return emptyList() + return buildList { + database.readableDatabase.rawQuery( + "SELECT entry_id,exercise_id,started_at,ended_at FROM " + + "session_timeline_exercises WHERE session_id=? ORDER BY started_at,entry_id", + arrayOf(context.sessionId), + ).use { cursor -> + while (cursor.moveToNext()) { + add( + SessionExerciseTiming( + sessionId = context.sessionId, + entryId = cursor.getString(0), + exerciseId = cursor.getString(1), + startedAt = cursor.getString(2), + endedAt = if (cursor.isNull(3)) null else cursor.getString(3), + ), + ) + } + } + } + } + + fun startActiveSessionExercise( + entryId: String, + startedAt: String = OffsetDateTime.now().toString(), + ): SessionExerciseTimingResult { + if (!SESSION_ENTRY_ID_V4_PATTERN.matches(entryId) || TrainlogTimestamp.parse(startedAt) == null) + return SessionExerciseTimingResult.Invalid("Invalid exercise timing.") + val db = database.writableDatabase + db.beginTransaction() + return try { + val active = + db.rawQuery( + "SELECT session_id,started_at FROM active_session_draft " + + "WHERE id=1 AND session_id IS NOT NULL AND started_at IS NOT NULL", + null, + ).use { cursor -> + if (!cursor.moveToFirst()) null else cursor.getString(0) to cursor.getString(1) + } ?: return SessionExerciseTimingResult.Invalid("No active session.") + db.execSQL( + "INSERT OR IGNORE INTO session_timeline_sessions(" + + "session_id,started_at,ended_at,acknowledged_at) VALUES(?,?,NULL,NULL)", + arrayOf(active.first, active.second), + ) + val exerciseId = + db.rawQuery( + "SELECT e.exercise_id FROM draft_session_exercises de " + + "JOIN exercises e ON e.id=de.exercise_row_id " + + "WHERE de.draft_id=1 AND de.entry_id=?", + arrayOf(entryId), + ).use { cursor -> + if (!cursor.moveToFirst()) null else cursor.getString(0) + } ?: return SessionExerciseTimingResult.Invalid("Exercise occurrence is not active.") + val existing = + db.rawQuery( + "SELECT exercise_id,started_at,ended_at FROM session_timeline_exercises " + + "WHERE session_id=? AND entry_id=?", + arrayOf(active.first, entryId), + ).use { cursor -> + if (!cursor.moveToFirst()) null + else Triple( + cursor.getString(0), + cursor.getString(1), + if (cursor.isNull(2)) null else cursor.getString(2), + ) + } + if (existing != null) { + if (existing.first != exerciseId) { + return SessionExerciseTimingResult.Invalid( + "Exercise identity conflicts with existing timing.", + ) + } + val timing = + SessionExerciseTiming( + active.first, + entryId, + exerciseId, + existing.second, + existing.third, + ) + return if (existing.third == null) { + SessionExerciseTimingResult.Started(timing) + } else { + SessionExerciseTimingResult.Invalid("Exercise has already been completed.") + } + } + val conflict = + db.rawQuery( + "SELECT entry_id FROM session_timeline_exercises WHERE active_slot=1 LIMIT 1", + null, + ).use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null } + if (conflict != null) return SessionExerciseTimingResult.ActiveConflict(conflict) + check(TrainlogTimestamp.parse(startedAt)!! >= TrainlogTimestamp.parse(active.second)!!) { + "Exercise cannot start before session." + } + db.execSQL( + "INSERT INTO session_timeline_exercises(" + + "session_id,entry_id,exercise_id,started_at,ended_at,active_slot) " + + "VALUES(?,?,?,?,NULL,1)", + arrayOf(active.first, entryId, exerciseId, startedAt), + ) + db.execSQL( + "UPDATE heart_rate_captures SET active_exercise_entry_id=? " + + "WHERE active_slot=1 AND context_id=? " + + "AND context_kind IN('session','cardio')", + arrayOf(entryId, active.first), + ) + val timing = + SessionExerciseTiming(active.first, entryId, exerciseId, startedAt, null) + db.setTransactionSuccessful() + SessionExerciseTimingResult.Started(timing) + } catch (error: IllegalStateException) { + SessionExerciseTimingResult.Invalid(error.message ?: "Invalid exercise timing.") + } catch (error: Exception) { + SessionExerciseTimingResult.DatabaseError(error.message ?: "Exercise start failed.") + } finally { + db.endTransaction() + } + } + + fun finishActiveSessionExercise( + entryId: String, + endedAt: String = OffsetDateTime.now().toString(), + ): SessionExerciseTimingResult { + if (!SESSION_ENTRY_ID_V4_PATTERN.matches(entryId) || TrainlogTimestamp.parse(endedAt) == null) + return SessionExerciseTimingResult.Invalid("Invalid exercise timing.") + val db = database.writableDatabase + db.beginTransaction() + return try { + val row = + db.rawQuery( + "SELECT session_id,exercise_id,started_at FROM session_timeline_exercises " + + "WHERE entry_id=? AND active_slot=1", + arrayOf(entryId), + ).use { cursor -> + if (!cursor.moveToFirst()) null + else Triple(cursor.getString(0), cursor.getString(1), cursor.getString(2)) + } ?: return SessionExerciseTimingResult.Invalid("Exercise is not active.") + check(TrainlogTimestamp.parse(endedAt)!! >= TrainlogTimestamp.parse(row.third)!!) { + "Exercise cannot end before it starts." + } + val values = ContentValues().apply { + put("ended_at", endedAt) + putNull("active_slot") + } + check( + db.update( + "session_timeline_exercises", + values, + "session_id=? AND entry_id=? AND active_slot=1", + arrayOf(row.first, entryId), + ) == 1, + ) + db.execSQL( + "UPDATE heart_rate_captures SET active_exercise_entry_id=NULL " + + "WHERE active_slot=1 AND context_id=? AND active_exercise_entry_id=?", + arrayOf(row.first, entryId), + ) + val timing = + SessionExerciseTiming(row.first, entryId, row.second, row.third, endedAt) + db.setTransactionSuccessful() + SessionExerciseTimingResult.Finished(timing) + } catch (error: IllegalStateException) { + SessionExerciseTimingResult.Invalid(error.message ?: "Invalid exercise timing.") + } catch (error: Exception) { + SessionExerciseTimingResult.DatabaseError(error.message ?: "Exercise end failed.") + } finally { + db.endTransaction() + } + } + + internal fun recordLiveHeartRateForActiveSession( + sensorName: String?, + observedAt: String, + measurement: ParsedHeartRateMeasurement, + ): HeartRateMutationResult? { + val context = activeSessionTimelineContext() ?: return null + val contextKind = + if (context.sessionType.wireValue == "cardio") { + HeartRateContextKind.CARDIO + } else { + HeartRateContextKind.SESSION + } + + var capture = activeHeartRateCapture() + if (capture == null) { + when ( + startHeartRateCapture( + contextKind, + context.sessionId, + context.startedAt, + sensorName, + ) + ) { + is StartHeartRateCaptureResult.Started -> capture = activeHeartRateCapture() + StartHeartRateCaptureResult.Conflict -> capture = activeHeartRateCapture() + else -> return HeartRateMutationResult.DatabaseError + } + } + + val current = capture ?: return HeartRateMutationResult.NotFound + if (current.contextId != context.sessionId || current.contextKind != contextKind) return null + + if (current.activeExerciseEntryId != context.activeEntryId) { + when (setHeartRateActiveExercise(current.captureId, context.activeEntryId)) { + is HeartRateMutationResult.Applied -> Unit + else -> return HeartRateMutationResult.DatabaseError + } + } + + return appendHeartRateSample( + captureId = current.captureId, + observedAt = observedAt, + bpm = measurement.bpm, + sensorContactDetected = measurement.sensorContactDetected, + energyExpended = measurement.energyExpended, + rrIntervals = + measurement.rrIntervals1024.mapIndexed { index, value -> + HeartRateRrInterval(index, value) + }, + ) + } + private fun heartRateContextIdValid(kind: HeartRateContextKind, value: String): Boolean = when (kind) { HeartRateContextKind.SESSION, @@ -1543,6 +1843,67 @@ class TrainlogRepository( .toString() } + internal fun buildSessionTimelineV1Json(): String { + val db = database.readableDatabase + val sessions = JSONArray() + db.rawQuery( + "SELECT t.session_id,t.started_at,t.ended_at FROM session_timeline_sessions t " + + "JOIN sessions s ON s.session_id=t.session_id " + + "WHERE t.ended_at IS NOT NULL AND s.started_at=t.started_at " + + "AND s.ended_at=t.ended_at AND t.acknowledged_at IS NULL " + + "AND EXISTS(SELECT 1 FROM session_timeline_exercises x " + + "WHERE x.session_id=t.session_id) AND NOT EXISTS(" + + "SELECT 1 FROM session_timeline_generation_sessions m " + + "JOIN sync_generations g ON g.generation_id=m.generation_id " + + "WHERE m.session_id=t.session_id AND " + + "g.status IN('captured','published','waiting_acknowledgement')) " + + "ORDER BY t.ended_at,t.session_id LIMIT ?", + arrayOf(MAX_SESSION_TIMELINE_SESSIONS_PER_GENERATION.toString()), + ).use { cursor -> + while (cursor.moveToNext()) { + val sessionId = cursor.getString(0) + val exerciseRows = JSONArray() + db.rawQuery( + "SELECT t.entry_id,t.exercise_id,t.started_at,t.ended_at " + + "FROM session_timeline_exercises t " + + "JOIN session_exercises se ON se.entry_id=t.entry_id " + + "JOIN sessions s ON s.id=se.session_row_id AND s.session_id=t.session_id " + + "JOIN exercises e ON e.id=se.exercise_row_id AND e.exercise_id=t.exercise_id " + + "WHERE t.session_id=? ORDER BY t.started_at,t.entry_id", + arrayOf(sessionId), + ).use { exerciseCursor -> + var count = 0 + while (exerciseCursor.moveToNext()) { + check(++count <= MAX_SESSION_TIMELINE_EXERCISES_PER_SESSION) + check(!exerciseCursor.isNull(3)) { + "Completed session timeline contains an active exercise." + } + exerciseRows.put( + JSONObject() + .put("entry_id", exerciseCursor.getString(0)) + .put("exercise_id", exerciseCursor.getString(1)) + .put("started_at", exerciseCursor.getString(2)) + .put("ended_at", exerciseCursor.getString(3)), + ) + } + } + sessions.put( + JSONObject() + .put("session_id", sessionId) + .put("started_at", cursor.getString(1)) + .put("ended_at", cursor.getString(2)) + .put("exercises", exerciseRows), + ) + } + } + return JSONObject() + .put("format", "trainlog-session-timeline") + .put("version", 1) + .put("generated_at", OffsetDateTime.now().toString()) + .put("sessions", sessions) + .toString() + } + /** Create a transactionally consistent SQLite snapshot without exposing WAL files. */ internal fun backupDatabaseTo(destination: File) { require(!destination.exists()) { "Backup snapshot destination already exists." } @@ -1590,6 +1951,7 @@ class TrainlogRepository( artifacts["program-executions"] = buildProgramExecutionsV1Json() artifacts["sleep-diary"] = buildSleepDiaryV1Json() artifacts["heart-rate"] = buildHeartRateV1Json() + artifacts["session-timeline"] = buildSessionTimelineV1Json() artifacts } @@ -3606,18 +3968,42 @@ class TrainlogRepository( fun discardActiveSessionDraft(): ActiveDraftMutationResult { + val db = database.writableDatabase + db.beginTransaction() return try { - database.writableDatabase.delete( + val sessionId = + db.rawQuery( + "SELECT session_id FROM active_session_draft WHERE id=?", + arrayOf(ACTIVE_DRAFT_ID.toString()), + ).use { cursor -> + if (!cursor.moveToFirst() || cursor.isNull(0)) null else cursor.getString(0) + } + db.delete( "active_session_draft", "id = ?", arrayOf(ACTIVE_DRAFT_ID.toString()), ) + if (sessionId != null) { + db.delete( + "heart_rate_captures", + "active_slot=1 AND context_id=? AND context_kind IN('session','cardio')", + arrayOf(sessionId), + ) + db.delete( + "session_timeline_sessions", + "session_id=? AND ended_at IS NULL", + arrayOf(sessionId), + ) + } + db.setTransactionSuccessful() ActiveDraftMutationResult.Saved } catch (error: Exception) { ActiveDraftMutationResult.Error( error.message ?: "Suppression du brouillon impossible." ) + } finally { + db.endTransaction() } } @@ -3664,6 +4050,20 @@ class TrainlogRepository( transactionOpen = false return FinalizeActiveDraftResult.Invalid("The requested execution draft is not active.") } + val activeEntryId = + db.rawQuery( + "SELECT entry_id FROM session_timeline_exercises " + + "WHERE session_id=? AND active_slot=1 LIMIT 1", + arrayOf(lifecycleSessionId), + ).use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null } + if (activeEntryId != null) { + db.endTransaction() + transactionOpen = false + return FinalizeActiveDraftResult.Invalid( + "ACTIVE_EXERCISE:$activeEntryId", + ) + } + val finalizedAt = OffsetDateTime.now().toString() val completed = SessionDraft( @@ -3693,8 +4093,21 @@ class TrainlogRepository( active.draft.sourceSessionId, forcedSessionId = lifecycleSessionId, forcedStartedAt = active.draft.startedAt, + forcedEndedAt = finalizedAt, ) + db.execSQL( + "UPDATE session_timeline_sessions SET ended_at=? " + + "WHERE session_id=? AND ended_at IS NULL", + arrayOf(finalizedAt, sessionId), + ) + db.execSQL( + "UPDATE heart_rate_captures SET ended_at=?,active_slot=NULL," + + "active_exercise_entry_id=NULL WHERE active_slot=1 AND context_id=? " + + "AND context_kind IN('session','cardio')", + arrayOf(finalizedAt, sessionId), + ) + /* INVARIANT: completion moves the exact Program provenance from * the singleton draft to history in the same transaction. */ db.execSQL( @@ -3726,7 +4139,7 @@ class TrainlogRepository( ContentValues().apply { put("session_id", lifecycleSessionId) put("final_revision_id", active.draft.revisionId) - put("finalized_at", OffsetDateTime.now().toString()) + put("finalized_at", finalizedAt) }, ) @@ -8664,6 +9077,8 @@ class TrainlogRepository( ) } + ensureSessionTimelineForPersistedDraft(db, draft) + /* CONTRACT: draft persistence currently rebuilds occurrence rows. * Preserve immutable feedback by stable entry_id across that rebuild; * feedback for an explicitly removed occurrence remains parent-owned @@ -8835,6 +9250,7 @@ class TrainlogRepository( generalCorrection: Boolean = false, forcedSessionId: String? = null, forcedStartedAt: String? = null, + forcedEndedAt: String? = null, ): String { val sessionId: String val sessionRowId: Long @@ -8849,7 +9265,7 @@ class TrainlogRepository( put("started_at", forcedStartedAt ?: OffsetDateTime.now().toString()) /* CONTRACT: this exact explicit finalization instant anchors * H+; old NULL values remain unknown and are never backfilled. */ - put("ended_at", OffsetDateTime.now().toString()) + put("ended_at", forcedEndedAt ?: OffsetDateTime.now().toString()) put("session_type", draft.sessionType.wireValue) } sessionRowId = db.insertOrThrow("sessions", null, sessionValues) @@ -8892,7 +9308,7 @@ class TrainlogRepository( .getOrPut(cursor.getString(0)) { mutableListOf() } .add(Triple(cursor.getString(1), cursor.getString(2), cursor.getString(3))) } if (!generalCorrection) db.execSQL("UPDATE sessions SET ended_at=? WHERE id=?;", - arrayOf(OffsetDateTime.now().toString(), sessionRowId)) + arrayOf(forcedEndedAt ?: OffsetDateTime.now().toString(), sessionRowId)) /* INVARIANT: child replacement and draft deletion are in the * caller's transaction; failure restores the completed baseline. */ db.delete( @@ -9963,6 +10379,8 @@ private const val MAX_HEART_RATE_CAPTURES_PER_GENERATION = 256 private const val MAX_HEART_RATE_SAMPLES_PER_CAPTURE = 200000 private const val MAX_HEART_RATE_RR_PER_SAMPLE = 64 private const val MAX_HEART_RATE_SENSOR_NAME_UTF8_BYTES = 160 +private const val MAX_SESSION_TIMELINE_SESSIONS_PER_GENERATION = 256 +private const val MAX_SESSION_TIMELINE_EXERCISES_PER_SESSION = 128 private const val MAX_AI_DRAFT_ENTRIES = 64 private const val MAX_AI_TARGET_SETS = 99 private const val MAX_AI_TARGET_REPS = 999 @@ -9981,7 +10399,7 @@ private class TrainlogDatabaseHelper( appContext, databaseName, null, - 27, + 28, ) { override fun onConfigure( db: SQLiteDatabase, @@ -10036,6 +10454,7 @@ private class TrainlogDatabaseHelper( createProgramExecutionProvenance(db) createSleepDiaryTables(db) createHeartRateTables(db) + createSessionTimelineTables(db) seedEquipment(db) } @@ -10257,6 +10676,16 @@ private class TrainlogDatabaseHelper( createHeartRateTables(db) version = 27 } + if (version < 28 && newVersion >= 28) { + /* WHY: exercise/cardio correlation requires genuine user-action + * boundaries that survive draft-row rebuilds. CONTRACT: v28 adds + * only stable session/occurrence timeline facts. INVARIANT: an + * existing active draft contributes only its already-authoritative + * session_id/started_at; no exercise timing is fabricated. */ + createSessionTimelineTables(db) + seedActiveSessionTimeline(db) + version = 28 + } if (version != newVersion) { error( @@ -10460,6 +10889,61 @@ private class TrainlogDatabaseHelper( ) } + private fun createSessionTimelineTables(db: SQLiteDatabase) { + db.execSQL( + """CREATE TABLE IF NOT EXISTS session_timeline_sessions( + session_id TEXT PRIMARY KEY, + started_at TEXT NOT NULL, + ended_at TEXT, + acknowledged_at TEXT, + CHECK(session_id GLOB 'se_*') + )""".trimIndent(), + ) + db.execSQL( + """CREATE TABLE IF NOT EXISTS session_timeline_exercises( + session_id TEXT NOT NULL + REFERENCES session_timeline_sessions(session_id) ON DELETE CASCADE, + entry_id TEXT NOT NULL, + exercise_id TEXT NOT NULL, + started_at TEXT NOT NULL, + ended_at TEXT, + active_slot INTEGER UNIQUE CHECK(active_slot IS NULL OR active_slot=1), + PRIMARY KEY(session_id,entry_id), + CHECK(entry_id GLOB 'sxe_*'), + CHECK(exercise_id GLOB 'ex_*') + )""".trimIndent(), + ) + db.execSQL( + """CREATE TABLE IF NOT EXISTS session_timeline_generation_sessions( + generation_id TEXT NOT NULL + REFERENCES sync_generations(generation_id) ON DELETE RESTRICT, + session_id TEXT NOT NULL + REFERENCES session_timeline_sessions(session_id) ON DELETE RESTRICT, + PRIMARY KEY(generation_id,session_id) + )""".trimIndent(), + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS session_timeline_exercises_time " + + "ON session_timeline_exercises(started_at,session_id,entry_id)", + ) + } + + private fun seedActiveSessionTimeline(db: SQLiteDatabase) { + db.rawQuery( + "SELECT session_id,started_at FROM active_session_draft " + + "WHERE id=1 AND session_id IS NOT NULL AND started_at IS NOT NULL", + null, + ).use { cursor -> + if (cursor.moveToFirst()) { + db.execSQL( + "INSERT OR IGNORE INTO session_timeline_sessions(" + + "session_id,started_at,ended_at,acknowledged_at) VALUES(?,?,NULL,NULL)", + arrayOf(cursor.getString(0), cursor.getString(1)), + ) + } + } + } + private fun createSyncDataLifecycleTables(db: SQLiteDatabase) { fun addColumn(table: String, name: String, declaration: String) { if (!tableHasColumn(db, table, name)) { 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 049948e..a4cce20 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 @@ -79,6 +79,24 @@ data class SessionDraft( val sessionType: SessionType = SessionType.TRAINING, ) +data class SessionExerciseTiming( + val sessionId: String, + val entryId: String, + val exerciseId: String, + val startedAt: String, + val endedAt: String?, +) { + val active: Boolean get() = endedAt == null +} + +data class ActiveSessionTimelineContext( + val sessionId: String, + val startedAt: String, + val sessionType: SessionType, + val activeEntryId: String?, +) + + data class SessionDraftForm( val selectedExercise: ExerciseProfile? = null, /** Null means creation; otherwise replace this durable draft entry. */ 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 94e0a81..bf3c490 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 @@ -29,7 +29,10 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -56,6 +59,7 @@ import com.labfytools.trainlog.data.FinalizeActiveDraftResult import com.labfytools.trainlog.data.ManualPercentMaxResult import com.labfytools.trainlog.data.SaveFeedbackResult import com.labfytools.trainlog.data.TrainlogRepository +import com.labfytools.trainlog.data.TrainlogRepository.SessionExerciseTimingResult import com.labfytools.trainlog.model.ActiveSessionDraft import com.labfytools.trainlog.model.ExerciseDataFields import com.labfytools.trainlog.model.ExerciseProfile @@ -152,6 +156,9 @@ fun SessionScreen( var pendingDestructiveEdit by remember { mutableStateOf?>(null) } var feedbackRevision by remember { mutableStateOf(0) } var reorderedDuringGesture by remember { mutableStateOf(false) } + var timelineRevision by remember { mutableStateOf(0) } + var pendingExerciseSwitch by remember { mutableStateOf?>(null) } + var pendingFinalizeActiveEntryId by remember { mutableStateOf(null) } val persistDraft: (ActiveSessionDraft, String?) -> Unit = @@ -191,6 +198,12 @@ fun SessionScreen( } val currentDraft = activeDraft!! + val exerciseTimings = + remember(timelineRevision, currentDraft.exercises.map { it.entryId }) { + repository.listActiveSessionExerciseTimings() + } + val timingByEntry = exerciseTimings.associateBy { it.entryId } + val activeTiming = exerciseTimings.firstOrNull { it.active } val lastWriteFailed = message?.startsWith( @@ -357,6 +370,88 @@ fun SessionScreen( ) } + val timing = timingByEntry[draft.entryId] + when { + timing == null -> { + TrainlogButton( + label = strings.getString(R.string.exercise_timeline_start), + onClick = { + when ( + val result = + repository.startActiveSessionExercise(draft.entryId) + ) { + is SessionExerciseTimingResult.Started -> { + timelineRevision++ + message = + strings.getString( + R.string.exercise_timeline_started, + draft.exercise.name, + ) + } + is SessionExerciseTimingResult.ActiveConflict -> { + pendingExerciseSwitch = + result.activeEntryId to draft.entryId + } + is SessionExerciseTimingResult.Invalid -> + message = result.message + is SessionExerciseTimingResult.DatabaseError -> + message = result.message + is SessionExerciseTimingResult.Finished -> Unit + } + }, + modifier = + Modifier + .fillMaxWidth() + .testTag("start-exercise-${draft.entryId}"), + style = TrainlogButtonStyle.SUCCESS, + ) + } + timing.active -> { + TrainlogButton( + label = strings.getString(R.string.exercise_timeline_finish), + onClick = { + when ( + val result = + repository.finishActiveSessionExercise(draft.entryId) + ) { + is SessionExerciseTimingResult.Finished -> { + timelineRevision++ + message = + strings.getString( + R.string.exercise_timeline_finished, + draft.exercise.name, + ) + } + is SessionExerciseTimingResult.Invalid -> + message = result.message + is SessionExerciseTimingResult.DatabaseError -> + message = result.message + is SessionExerciseTimingResult.Started, + is SessionExerciseTimingResult.ActiveConflict -> Unit + } + }, + modifier = + Modifier + .fillMaxWidth() + .testTag("finish-exercise-${draft.entryId}"), + containerColor = colors.warning, + style = TrainlogButtonStyle.SECONDARY, + ) + } + else -> { + TrainlogButton( + label = strings.getString(R.string.exercise_timeline_completed), + onClick = {}, + modifier = + Modifier + .fillMaxWidth() + .testTag("completed-exercise-${draft.entryId}"), + enabled = false, + style = TrainlogButtonStyle.SECONDARY, + ) + } + } + TrainlogAction( label = strings.getString(R.string.modify_name, draft.exercise.name), description = strings.getString(R.string.edit_exercise_description), @@ -518,22 +613,30 @@ fun SessionScreen( Modifier.weight(1f).fillMaxHeight().testTag("active-session-create-exercise"), containerColor = colors.surfaceAlt, maxLines = 2) TrainlogButton(strings.getString(R.string.session_save), onClick = { - when ( - val result = - repository.finalizeActiveSessionDraft() - ) { - is FinalizeActiveDraftResult.Saved -> { - onSessionSaved() - onBack() - } + val activeEntryId = repository.activeSessionTimelineContext()?.activeEntryId + if (activeEntryId != null) { + pendingFinalizeActiveEntryId = activeEntryId + } else { + when ( + val result = + repository.finalizeActiveSessionDraft() + ) { + is FinalizeActiveDraftResult.Saved -> { + onSessionSaved() + onBack() + } - is FinalizeActiveDraftResult.Invalid -> { - message = localizedRepositoryMessage(strings, result.message) - } + is FinalizeActiveDraftResult.Invalid -> { + message = localizedRepositoryMessage(strings, result.message) + } - is FinalizeActiveDraftResult.DatabaseError -> { - message = - strings.getString(R.string.finalize_failed, localizedRepositoryMessage(strings, result.message)) + is FinalizeActiveDraftResult.DatabaseError -> { + message = + strings.getString( + R.string.finalize_failed, + localizedRepositoryMessage(strings, result.message), + ) + } } } }, modifier = Modifier.weight(1f).fillMaxHeight().testTag("active-session-save"), @@ -589,7 +692,19 @@ fun SessionScreen( strings.getString(R.string.exercise_added) || message == strings.getString(R.string.exercise_removed) || - message == strings.getString(R.string.exercise_order_saved) + message == strings.getString(R.string.exercise_order_saved) || + currentDraft.exercises.any { exercise -> + message == + strings.getString( + R.string.exercise_timeline_started, + exercise.exercise.name, + ) || + message == + strings.getString( + R.string.exercise_timeline_finished, + exercise.exercise.name, + ) + } ) { colors.success } else { @@ -640,6 +755,138 @@ fun SessionScreen( }, ) } + + pendingExerciseSwitch?.let { (activeEntryId, targetEntryId) -> + val activeName = + currentDraft.exercises.firstOrNull { it.entryId == activeEntryId } + ?.exercise?.name + ?: strings.getString(R.string.exercise_title) + val targetName = + currentDraft.exercises.firstOrNull { it.entryId == targetEntryId } + ?.exercise?.name + ?: strings.getString(R.string.exercise_title) + AlertDialog( + onDismissRequest = { pendingExerciseSwitch = null }, + title = { Text(strings.getString(R.string.exercise_timeline_switch_title)) }, + text = { + Text( + strings.getString( + R.string.exercise_timeline_switch_detail, + activeName, + targetName, + ), + ) + }, + confirmButton = { + TextButton( + onClick = { + when ( + val finished = + repository.finishActiveSessionExercise(activeEntryId) + ) { + is SessionExerciseTimingResult.Finished -> { + when ( + val started = + repository.startActiveSessionExercise(targetEntryId) + ) { + is SessionExerciseTimingResult.Started -> { + timelineRevision++ + message = + strings.getString( + R.string.exercise_timeline_started, + targetName, + ) + } + is SessionExerciseTimingResult.Invalid -> + message = started.message + is SessionExerciseTimingResult.DatabaseError -> + message = started.message + is SessionExerciseTimingResult.ActiveConflict, + is SessionExerciseTimingResult.Finished -> Unit + } + } + is SessionExerciseTimingResult.Invalid -> + message = finished.message + is SessionExerciseTimingResult.DatabaseError -> + message = finished.message + is SessionExerciseTimingResult.Started, + is SessionExerciseTimingResult.ActiveConflict -> Unit + } + pendingExerciseSwitch = null + }, + ) { + Text(strings.getString(R.string.exercise_timeline_switch_confirm)) + } + }, + dismissButton = { + TextButton(onClick = { pendingExerciseSwitch = null }) { + Text(strings.getString(R.string.dialog_cancel)) + } + }, + ) + } + + pendingFinalizeActiveEntryId?.let { activeEntryId -> + val activeName = + currentDraft.exercises.firstOrNull { it.entryId == activeEntryId } + ?.exercise?.name + ?: strings.getString(R.string.exercise_title) + AlertDialog( + onDismissRequest = { pendingFinalizeActiveEntryId = null }, + title = { Text(strings.getString(R.string.exercise_timeline_finalize_title)) }, + text = { + Text( + strings.getString( + R.string.exercise_timeline_finalize_detail, + activeName, + ), + ) + }, + confirmButton = { + TextButton( + onClick = { + when ( + val finished = + repository.finishActiveSessionExercise(activeEntryId) + ) { + is SessionExerciseTimingResult.Finished -> { + timelineRevision++ + when (val result = repository.finalizeActiveSessionDraft()) { + is FinalizeActiveDraftResult.Saved -> { + pendingFinalizeActiveEntryId = null + onSessionSaved() + onBack() + } + is FinalizeActiveDraftResult.Invalid -> + message = localizedRepositoryMessage(strings, result.message) + is FinalizeActiveDraftResult.DatabaseError -> + message = + strings.getString( + R.string.finalize_failed, + localizedRepositoryMessage(strings, result.message), + ) + } + } + is SessionExerciseTimingResult.Invalid -> + message = finished.message + is SessionExerciseTimingResult.DatabaseError -> + message = finished.message + is SessionExerciseTimingResult.Started, + is SessionExerciseTimingResult.ActiveConflict -> Unit + } + pendingFinalizeActiveEntryId = null + }, + ) { + Text(strings.getString(R.string.exercise_timeline_finalize_confirm)) + } + }, + dismissButton = { + TextButton(onClick = { pendingFinalizeActiveEntryId = null }) { + Text(strings.getString(R.string.dialog_cancel)) + } + }, + ) + } } } diff --git a/android/app/src/main/res/values-en/strings.xml b/android/app/src/main/res/values-en/strings.xml index a53c26f..76a56f0 100644 --- a/android/app/src/main/res/values-en/strings.xml +++ b/android/app/src/main/res/values-en/strings.xml @@ -525,4 +525,15 @@ Heart-rate sensor configured. Forget sensor Stop heart-rate connection and remove the remembered sensor. + Start exercise + Finish exercise + Exercise completed + %1$s · started + %1$s · finished + Switch exercise? + %1$s is still active. Finish it now and then start %2$s? + Finish and start + An exercise is still active + %1$s is still active. Finish it now and then save the session? + Finish and save diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index d967856..f51ba53 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -535,4 +535,15 @@ Capteur cardio configuré. Oublier le capteur Arrêter la connexion cardio et supprimer le capteur mémorisé. + Démarrer l’exercice + Terminer l’exercice + Exercice terminé + %1$s · démarré + %1$s · terminé + Changer d’exercice ? + %1$s est encore en cours. Le terminer maintenant puis démarrer %2$s ? + Terminer et démarrer + Un exercice est encore en cours + %1$s est encore actif. Le terminer maintenant puis enregistrer la séance ? + Terminer et enregistrer 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 33d9b2f..ab6fe4b 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(27, scalar(db, "PRAGMA user_version")) + assertEquals(28, 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 699fc45..8d8f108 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(27, scalar(db, "PRAGMA user_version")) + assertEquals(28, 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 1b032ea..f691ec4 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(27, scalar(db, "PRAGMA user_version")) + assertEquals(28, 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 9800645..ed8afd3 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(27, scalar(db, "PRAGMA user_version")) + assertEquals(28, 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 b0561ec..dab43be 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(27, it.version) } + ).use { assertEquals(28, 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 f26c990..3d58f51 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(27, it.version) } + ).use { assertEquals(28, 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 new file mode 100644 index 0000000..fec8981 --- /dev/null +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV28SessionTimelineMigrationTest.kt @@ -0,0 +1,105 @@ +package com.labfytools.trainlog.data + +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import androidx.test.core.app.ApplicationProvider +import com.labfytools.trainlog.model.NewExerciseProfile +import com.labfytools.trainlog.model.RecordingMode +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.assertNotNull +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 AndroidV28SessionTimelineMigrationTest { + private val context: Context = ApplicationProvider.getApplicationContext() + + @Test + fun v27SeedsOnlyAuthoritativeActiveSessionBoundary() { + val name = "timeline-v27-${UUID.randomUUID()}.db" + var repository = TrainlogRepository(context, name) + try { + assertEquals( + ActiveDraftMutationResult.Saved, + repository.startActiveSessionDraft(), + ) + val exercise = + (repository.createExercise( + NewExerciseProfile( + "Timeline migration exercise", + RecordingMode.SETS, + TrackingMode.REPS, + 0, + ), + ) as CreateExerciseResult.Created).exercise + val loaded = + repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded + val sessionId = checkNotNull(loaded.draft.sessionId) + val startedAt = checkNotNull(loaded.draft.startedAt) + assertEquals( + ActiveDraftMutationResult.Saved, + repository.saveActiveSessionDraft( + loaded.draft.copy( + exercises = + listOf( + SessionExerciseDraft( + exercise = exercise, + sets = listOf(SessionSetDraft(reps = 1)), + ), + ), + ), + ), + ) + repository.close() + + SQLiteDatabase.openDatabase( + context.getDatabasePath(name).path, + null, + SQLiteDatabase.OPEN_READWRITE, + ).use { db -> + db.execSQL("DROP TABLE session_timeline_generation_sessions") + db.execSQL("DROP TABLE session_timeline_exercises") + db.execSQL("DROP TABLE session_timeline_sessions") + db.version = 27 + } + + repository = TrainlogRepository(context, name) + assertNotNull(repository.activeSessionTimelineContext()) + SQLiteDatabase.openDatabase( + context.getDatabasePath(name).path, + null, + SQLiteDatabase.OPEN_READONLY, + ).use { db -> + assertEquals(28, db.version) + db.rawQuery( + "SELECT session_id,started_at,ended_at FROM session_timeline_sessions", + null, + ).use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals(sessionId, cursor.getString(0)) + assertEquals(startedAt, cursor.getString(1)) + assertTrue(cursor.isNull(2)) + } + db.rawQuery( + "SELECT COUNT(*) FROM session_timeline_exercises", + null, + ).use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals(0, cursor.getInt(0)) + } + } + assertNotNull(repository.activeSessionTimelineContext()) + } 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 dfef645..1e9f585 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(27, cursor.getInt(0)) + assertTrue(cursor.moveToFirst()); assertEquals(28, 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 23ae36f..60dec75 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(27, it.version) } + .use { assertEquals(28, it.version) } } finally { repository.close() context.deleteDatabase(name) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/SessionPreparationExchangeTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/SessionPreparationExchangeTest.kt index 5ef611c..8f12961 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("27", scalarText(name, "PRAGMA user_version")) + assertEquals("28", 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/SessionTimelineRepositoryTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/SessionTimelineRepositoryTest.kt new file mode 100644 index 0000000..6af23f8 --- /dev/null +++ b/android/app/src/test/java/com/labfytools/trainlog/data/SessionTimelineRepositoryTest.kt @@ -0,0 +1,181 @@ +package com.labfytools.trainlog.data + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.labfytools.trainlog.model.NewExerciseProfile +import com.labfytools.trainlog.model.RecordingMode +import com.labfytools.trainlog.model.SessionExerciseDraft +import com.labfytools.trainlog.model.SessionSetDraft +import com.labfytools.trainlog.model.TrackingMode +import java.time.OffsetDateTime +import java.util.UUID +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +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 SessionTimelineRepositoryTest { + private val context: Context = ApplicationProvider.getApplicationContext() + + @Test + fun startedOccurrenceCannotBeRemovedOrSilentlyClosedBySessionFinalization() { + val name = "timeline-guard-" + UUID.randomUUID() + ".db" + val repository = TrainlogRepository(context, name) + try { + val exercise = + (repository.createExercise( + NewExerciseProfile( + "Timeline guarded exercise", + RecordingMode.SETS, + TrackingMode.REPS, + 0, + ), + ) as CreateExerciseResult.Created).exercise + assertEquals( + ActiveDraftMutationResult.Saved, + repository.startActiveSessionDraft(), + ) + val opened = + repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded + val occurrence = + SessionExerciseDraft( + exercise = exercise, + sets = listOf(SessionSetDraft(reps = 5)), + ) + val activeDraft = opened.draft.copy(exercises = listOf(occurrence)) + assertEquals( + ActiveDraftMutationResult.Saved, + repository.saveActiveSessionDraft(activeDraft), + ) + assertTrue( + repository.startActiveSessionExercise(occurrence.entryId) is + TrainlogRepository.SessionExerciseTimingResult.Started, + ) + + val removed = repository.saveActiveSessionDraft(activeDraft.copy(exercises = emptyList())) + assertTrue(removed is ActiveDraftMutationResult.Error) + val finalize = repository.finalizeActiveSessionDraft() + assertTrue(finalize is FinalizeActiveDraftResult.Invalid) + assertTrue( + (finalize as FinalizeActiveDraftResult.Invalid) + .message + .startsWith("ACTIVE_EXERCISE:"), + ) + + assertTrue( + repository.finishActiveSessionExercise(occurrence.entryId) is + TrainlogRepository.SessionExerciseTimingResult.Finished, + ) + assertTrue( + repository.finalizeActiveSessionDraft() is + FinalizeActiveDraftResult.Saved, + ) + } finally { + repository.close() + context.deleteDatabase(name) + } + } + + @Test + fun trainingTimelineCorrelatesMeasuredSamplesWithoutInventingRestOwner() { + val name = "timeline-${UUID.randomUUID()}.db" + val repository = TrainlogRepository(context, name) + try { + fun exercise(label: String) = + (repository.createExercise( + NewExerciseProfile(label, RecordingMode.SETS, TrackingMode.REPS, 0), + ) as CreateExerciseResult.Created).exercise + + val first = exercise("Timeline A") + val second = exercise("Timeline B") + assertEquals(ActiveDraftMutationResult.Saved, repository.startActiveSessionDraft()) + val opened = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded + val firstEntry = SessionExerciseDraft( + exercise = first, + sets = listOf(SessionSetDraft(reps = 10)), + ) + val secondEntry = SessionExerciseDraft( + exercise = second, + sets = listOf(SessionSetDraft(reps = 10)), + ) + assertEquals( + ActiveDraftMutationResult.Saved, + repository.saveActiveSessionDraft( + opened.draft.copy(exercises = listOf(firstEntry, secondEntry)), + ), + ) + + val startA = OffsetDateTime.now().toString() + val startedA = repository.startActiveSessionExercise(firstEntry.entryId, startA) + assertTrue(startedA is TrainlogRepository.SessionExerciseTimingResult.Started) + val conflict = repository.startActiveSessionExercise(secondEntry.entryId) + assertEquals( + firstEntry.entryId, + (conflict as TrainlogRepository.SessionExerciseTimingResult.ActiveConflict).activeEntryId, + ) + + val measurement = ParsedHeartRateMeasurement( + bpm = 101, + sensorContactDetected = null, + energyExpended = null, + rrIntervals1024 = listOf(620), + ) + assertTrue( + repository.recordLiveHeartRateForActiveSession( + "Synthetic HR", OffsetDateTime.now().toString(), measurement, + ) is TrainlogRepository.HeartRateMutationResult.Applied, + ) + assertTrue( + repository.finishActiveSessionExercise(firstEntry.entryId) + is TrainlogRepository.SessionExerciseTimingResult.Finished, + ) + + assertTrue( + repository.recordLiveHeartRateForActiveSession( + "Synthetic HR", OffsetDateTime.now().toString(), measurement.copy(bpm = 88), + ) is TrainlogRepository.HeartRateMutationResult.Applied, + ) + assertTrue( + repository.startActiveSessionExercise(secondEntry.entryId) + is TrainlogRepository.SessionExerciseTimingResult.Started, + ) + assertTrue( + repository.recordLiveHeartRateForActiveSession( + "Synthetic HR", OffsetDateTime.now().toString(), measurement.copy(bpm = 109), + ) is TrainlogRepository.HeartRateMutationResult.Applied, + ) + assertTrue( + repository.finishActiveSessionExercise(secondEntry.entryId) + is TrainlogRepository.SessionExerciseTimingResult.Finished, + ) + val finalized = repository.finalizeActiveSessionDraft() + assertTrue(finalized is FinalizeActiveDraftResult.Saved) + + val timeline = JSONObject(repository.buildSessionTimelineV1Json()) + val session = timeline.getJSONArray("sessions").getJSONObject(0) + assertEquals(2, session.getJSONArray("exercises").length()) + assertEquals(firstEntry.entryId, session.getJSONArray("exercises").getJSONObject(0).getString("entry_id")) + assertEquals(secondEntry.entryId, session.getJSONArray("exercises").getJSONObject(1).getString("entry_id")) + + val heart = JSONObject(repository.buildHeartRateV1Json()) + val samples = heart.getJSONArray("captures").getJSONObject(0).getJSONArray("samples") + assertEquals(3, samples.length()) + assertEquals(firstEntry.entryId, samples.getJSONObject(0).getString("exercise_entry_id")) + assertTrue(samples.getJSONObject(1).isNull("exercise_entry_id")) + assertEquals(secondEntry.entryId, samples.getJSONObject(2).getString("exercise_entry_id")) + assertEquals(101, samples.getJSONObject(0).getInt("bpm")) + assertEquals(88, samples.getJSONObject(1).getInt("bpm")) + assertEquals(109, samples.getJSONObject(2).getInt("bpm")) + assertNull(repository.activeHeartRateCapture()) + } finally { + repository.close() + context.deleteDatabase(name) + } + } +} diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/SessionTimelineSyncGenerationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/SessionTimelineSyncGenerationTest.kt new file mode 100644 index 0000000..998abda --- /dev/null +++ b/android/app/src/test/java/com/labfytools/trainlog/data/SessionTimelineSyncGenerationTest.kt @@ -0,0 +1,209 @@ +package com.labfytools.trainlog.data + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.labfytools.trainlog.model.NewExerciseProfile +import com.labfytools.trainlog.model.RecordingMode +import com.labfytools.trainlog.model.SessionExerciseDraft +import com.labfytools.trainlog.model.SessionSetDraft +import com.labfytools.trainlog.model.TrackingMode +import java.nio.file.Files +import java.time.OffsetDateTime +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 SessionTimelineSyncGenerationTest { + private val context: Context = ApplicationProvider.getApplicationContext() + + @Test + fun timelineAndHeartRateArriveTogetherAndStopPublishingAfterAck() { + val sourceName = "timeline-sync-" + UUID.randomUUID() + ".db" + val root = Files.createTempDirectory("trainlog-timeline-sync-").toFile() + val source = TrainlogRepository(context, sourceName) + try { + fun exercise(label: String) = + (source.createExercise( + NewExerciseProfile(label, RecordingMode.SETS, TrackingMode.REPS, 0), + ) as CreateExerciseResult.Created).exercise + val a = SessionExerciseDraft( + exercise = exercise("Sync timeline A"), + sets = listOf(SessionSetDraft(reps = 8)), + ) + val b = SessionExerciseDraft( + exercise = exercise("Sync timeline B"), + sets = listOf(SessionSetDraft(reps = 9)), + ) + assertEquals(ActiveDraftMutationResult.Saved, source.startActiveSessionDraft()) + val opened = source.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded + assertEquals( + ActiveDraftMutationResult.Saved, + source.saveActiveSessionDraft(opened.draft.copy(exercises = listOf(a, b))), + ) + assertTrue( + source.startActiveSessionExercise(a.entryId) is + TrainlogRepository.SessionExerciseTimingResult.Started, + ) + val base = ParsedHeartRateMeasurement(96, null, null, listOf(640)) + assertTrue( + source.recordLiveHeartRateForActiveSession( + "Synthetic HR", + OffsetDateTime.now().toString(), + base, + ) is TrainlogRepository.HeartRateMutationResult.Applied, + ) + assertTrue( + source.finishActiveSessionExercise(a.entryId) is + TrainlogRepository.SessionExerciseTimingResult.Finished, + ) + assertTrue( + source.recordLiveHeartRateForActiveSession( + "Synthetic HR", + OffsetDateTime.now().toString(), + base.copy(bpm = 84), + ) is TrainlogRepository.HeartRateMutationResult.Applied, + ) + assertTrue( + source.startActiveSessionExercise(b.entryId) is + TrainlogRepository.SessionExerciseTimingResult.Started, + ) + assertTrue( + source.recordLiveHeartRateForActiveSession( + "Synthetic HR", + OffsetDateTime.now().toString(), + base.copy(bpm = 105), + ) is TrainlogRepository.HeartRateMutationResult.Applied, + ) + assertTrue( + source.finishActiveSessionExercise(b.entryId) is + TrainlogRepository.SessionExerciseTimingResult.Finished, + ) + assertTrue(source.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved) + + 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 timelineJson = + JSONObject( + java.io.File( + captured.stagingDirectory, + "session-timeline-v1.json", + ).readText(), + ) + assertEquals(1, timelineJson.getJSONArray("sessions").length()) + assertEquals( + 2, + timelineJson + .getJSONArray("sessions") + .getJSONObject(0) + .getJSONArray("exercises") + .length(), + ) + val heartJson = + JSONObject( + java.io.File(captured.stagingDirectory, "heart-rate-v1.json").readText(), + ) + assertEquals( + 3, + heartJson + .getJSONArray("captures") + .getJSONObject(0) + .getJSONArray("samples") + .length(), + ) + + 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("acknowledged", service.acceptAcknowledgement(ackFile.readBytes())) + + assertEquals( + "2|3|3", + run( + "sqlite3", + desktopDb.absolutePath, + "SELECT (SELECT count(*) FROM session_exercise_timeline) || '|' || " + + "(SELECT count(*) FROM heart_rate_samples) || '|' || " + + "(SELECT count(*) FROM heart_rate_rr_intervals);", + ).trim(), + ) + assertEquals( + 0, + JSONObject(source.buildSessionTimelineV1Json()) + .getJSONArray("sessions") + .length(), + ) + assertEquals( + 0, + JSONObject(source.buildHeartRateV1Json()) + .getJSONArray("captures") + .length(), + ) + } finally { + source.close() + context.deleteDatabase(sourceName) + root.deleteRecursively() + } + } +} 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 1d8f697..234d64e 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(27, it.getInt(0)) + assertEquals(28, 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 9e7ed26..9c46d8e 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(27, cursor.getInt(0)) + assertEquals(28, 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(27, cursor.getInt(0)) + assertEquals(28, 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 de6a198..4def2cb 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 v30; synchronized Heart Rate V1 storage is additive in v30 | -| Android SQLite | schema v27; Android-owned Heart Rate V1 capture storage is additive in v27 | +| Desktop SQLite | schema v31; session exercise timeline import is additive in v31 | +| Android SQLite | schema v28; active training timeline/outbox is additive in v28 | | 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` | @@ -150,7 +150,8 @@ parallel implementation of its rules. | Cardio persistence/sync | `TRAINLOG_CARDIO_PERSISTENCE_SYNC_V1=PASS`: Android v27 + desktop v30, strict optional `trainlog-heart-rate` V1 Android→desktop companion, generation relation and correlated ACK | | Cardio BLE diagnostics | `TRAINLOG_CARDIO_BLE_DIAGNOSTICS_V1=PASS`: real CYCPLUS H2 validated on Android; `0x180D` + `0x2A37`, Battery Service, approximately 1 Hz notifications and one raw RR interval per observed notification; contact and energy fields absent in the observed payloads | | 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 | -| Current operational cursor | `TRAINLOG_CARDIO_TRAINING_TIMELINE_V1`: persist real session/exercise start/end boundaries and correlate live heart-rate samples with the active stable `entry_id` | +| 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 | +| Current operational cursor | `TRAINLOG_SLEEP_ANDROID_ONE_TAP_V1`: add the Android Sleep entry point with one-tap Couché / medication / Réveil / Levé, immediate undo, later correction, and sleep-linked cardio capture | | 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 38d634c..05476df 100644 --- a/docs/database.md +++ b/docs/database.md @@ -1,5 +1,19 @@ # Desktop database +## Schema v31: synchronized session exercise timeline + +Schema v31 additively owns Android-recorded execution timing for completed +session occurrences in `session_exercise_timeline`. Each row is keyed by the +stable `session_id + entry_id` pair and stores the matching catalogue +`exercise_id`, the real user-action `started_at` and `ended_at`, and import +time. The importer requires the existing canonical session and occurrence to +match; it never creates workout history or infers exercise timing from order, +sets, plans, or heart-rate samples. + +The session bounds remain the existing canonical `sessions.started_at` and +`sessions.ended_at`. The separate `trainlog-session-timeline` V1 companion +must match those exact values before occurrence timing can be imported. + ## Schema v30: synchronized Heart Rate V1 Schema v30 additively owns Android-recorded heart-rate captures after diff --git a/docs/roadmap.md b/docs/roadmap.md index 5422398..51da3df 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_TRAINING_TIMELINE_V1 +CURRENT_OPERATIONAL_CURSOR=TRAINLOG_SLEEP_ANDROID_ONE_TAP_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 887d9f3..491e70e 100644 --- a/docs/sync_exchange.md +++ b/docs/sync_exchange.md @@ -1089,7 +1089,23 @@ MTP recovery, manifest/ACK framing, Sleep Diary V1 nor mobile-export semantics. Heart-rate BLE/GATT acquisition remains Android-only and is a separate implementation layer. -## 20. Legacy active-protocol limitations +## 20. Session timeline companion V1 + +`trainlog-session-timeline` V1 is an optional Android→desktop full-generation +participant. Android records the real execution interval of each stable +`entry_id`; session start/end reuse the same lifecycle identity and timestamps +that become canonical completed history. + +Only completed timelines are published. The desktop v31 consumer first proves +that `session_id`, `started_at`, `ended_at`, `entry_id` and `exercise_id` +already match canonical history, then inserts the occurrence timing facts. +Exact replay is a no-op and conflicting reuse of an identity is rejected. + +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 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 f9119cc..65c07f5 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -1727,3 +1727,20 @@ reloaded the remembered sensor and returned automatically at `♥ 72 BPM`. Disabling phone Bluetooth produced `♥ -- BPM` with the reconnecting state; reenabling Bluetooth restored the same foreground service automatically and returned to a fresh `♥ 79 BPM`. No uninstall or data clear occurred. + +## Training timeline V1 validation + +`TRAINLOG_CARDIO_TRAINING_TIMELINE_V1=PASS` covers Android schema v28 and +desktop schema v31. Repository tests prove explicit exercise start/end +timestamps, one-active-exercise enforcement, restart-safe active ownership, +finalization refusal while an exercise remains active, and heart-rate samples +tagged with the active stable `entry_id` while rest samples remain unowned. + +The generation regression publishes a completed timeline through the production +full-generation path, imports it through `trainlog-session-timeline` V1, +accepts the correlated ACK, and verifies idempotent replay/conflict rejection. + +The 2026-09-23 closeout passes the complete native Meson inventory: +**104/104 passed, 0 failed**. The complete Android JVM inventory reports +**272 tests: 267 passed, five skipped, zero failures/errors**; `assembleDebug` +also succeeds. The five skips remain optional historical/external fixture gates. diff --git a/tests/test_session_timeline_exchange.py b/tests/test_session_timeline_exchange.py new file mode 100644 index 0000000..9bcd602 --- /dev/null +++ b/tests/test_session_timeline_exchange.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 + +import sqlite3 +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 session_timeline_exchange as timeline + +SESSION = "se_11111111-1111-4111-8111-111111111111" +ENTRY = "sxe_22222222-2222-4222-8222-222222222222" +EXERCISE = "ex_33333333-3333-4333-8333-333333333333" + + +def schema(db: sqlite3.Connection) -> None: + db.executescript( + """ + CREATE TABLE sessions( + id INTEGER PRIMARY KEY, + session_id TEXT UNIQUE, + started_at TEXT, + ended_at TEXT + ); + CREATE TABLE exercises( + id INTEGER PRIMARY KEY, + exercise_id TEXT UNIQUE + ); + CREATE TABLE session_exercises( + id INTEGER PRIMARY KEY, + session_row_id INTEGER, + exercise_row_id INTEGER, + entry_id TEXT UNIQUE + ); + CREATE TABLE session_exercise_timeline( + session_id TEXT, + entry_id TEXT, + exercise_id TEXT, + started_at TEXT, + ended_at TEXT, + imported_at TEXT, + PRIMARY KEY(session_id,entry_id) + ); + PRAGMA user_version=31; + """ + ) + db.execute( + "INSERT INTO sessions(id,session_id,started_at,ended_at) VALUES(1,?,?,?)", + (SESSION, "2026-09-23T08:00:00+02:00", "2026-09-23T09:00:00+02:00"), + ) + db.execute("INSERT INTO exercises(id,exercise_id) VALUES(1,?)", (EXERCISE,)) + db.execute( + "INSERT INTO session_exercises(id,session_row_id,exercise_row_id,entry_id) " + "VALUES(1,1,1,?)", + (ENTRY,), + ) + + +def document() -> dict: + return { + "format": "trainlog-session-timeline", + "version": 1, + "generated_at": "2026-09-23T09:01:00+02:00", + "sessions": [ + { + "session_id": SESSION, + "started_at": "2026-09-23T08:00:00+02:00", + "ended_at": "2026-09-23T09:00:00+02:00", + "exercises": [ + { + "entry_id": ENTRY, + "exercise_id": EXERCISE, + "started_at": "2026-09-23T08:10:00+02:00", + "ended_at": "2026-09-23T08:20:00+02:00", + } + ], + } + ], + } + + +class SessionTimelineExchangeTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.db = sqlite3.connect(Path(self.temp.name) / "timeline.sqlite") + schema(self.db) + + def tearDown(self): + self.db.close() + self.temp.cleanup() + + def test_import_and_exact_replay(self): + payload = timeline.validate(document()) + self.assertEqual((1, 0), timeline.apply(self.db, payload)) + self.assertEqual((0, 1), timeline.apply(self.db, payload)) + self.assertEqual( + ( + SESSION, + ENTRY, + EXERCISE, + "2026-09-23T08:10:00+02:00", + "2026-09-23T08:20:00+02:00", + ), + self.db.execute( + "SELECT session_id,entry_id,exercise_id,started_at,ended_at " + "FROM session_exercise_timeline" + ).fetchone(), + ) + + def test_rejects_identity_reuse_with_different_content(self): + payload = timeline.validate(document()) + timeline.apply(self.db, payload) + changed = deepcopy(document()) + changed["sessions"][0]["exercises"][0]["ended_at"] = "2026-09-23T08:21:00+02:00" + with self.assertRaisesRegex(ValueError, "different content"): + timeline.apply(self.db, timeline.validate(changed)) + + def test_rejects_occurrence_that_does_not_match_history(self): + payload = document() + payload["sessions"][0]["exercises"][0]["exercise_id"] = ( + "ex_44444444-4444-4444-8444-444444444444" + ) + with self.assertRaisesRegex(ValueError, "does not match session history"): + timeline.apply(self.db, timeline.validate(payload)) + + def test_rejects_exercise_outside_session_bounds(self): + payload = document() + payload["sessions"][0]["exercises"][0]["started_at"] = "2026-09-23T07:59:59+02:00" + with self.assertRaisesRegex(ValueError, "outside session"): + timeline.validate(payload) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_sync_deployment_tools.py b/tests/test_sync_deployment_tools.py index 401dbdf..5b0f496 100644 --- a/tests/test_sync_deployment_tools.py +++ b/tests/test_sync_deployment_tools.py @@ -66,13 +66,14 @@ 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"], 30) - self.assertEqual(inventory["android_schema"], 27) + self.assertEqual(inventory["desktop_schema"], 31) + self.assertEqual(inventory["android_schema"], 28) 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("heart-rate-v1", inventory["protocols"]) + self.assertIn("session-timeline-v1", inventory["protocols"]) self.assertIn("bluetooth-files-v1", inventory["protocols"]) self.assertTrue((output / "bin/trainlog").is_file()) self.assertTrue((output / "bin/trainlog-sync-once").is_file()) diff --git a/tools/causal_delete_exchange.py b/tools/causal_delete_exchange.py index 3984e4c..f92cd78 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): - raise CausalError(f"desktop schema v20-v30 required, found v{version}") + 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}") 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 3e68a37..895b6cd 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): - raise LifecycleError(f"desktop schema v19-v30 required, found v{version}") + 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}") def import_document(connection, document, own_transaction=True): diff --git a/tools/export_ai_history.py b/tools/export_ai_history.py index d19f592..4b8f3ae 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) +SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31) 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-v30 requis") + raise ValueError("schema desktop v15-v31 requis") root = { "format": "TRAINLOG_AI_EXPORT", diff --git a/tools/export_ai_session_drafts.py b/tools/export_ai_session_drafts.py index ec88dfe..5efc032 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) + supported_versions = (18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - raise ValueError("schema desktop v18-v30 requis") + raise ValueError("schema desktop v18-v31 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 6e639e6..aa015a2 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, 31)) + supported_versions = tuple(range(8, 32)) if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions: - raise ValueError("schema desktop v8 à v30 requis") + raise ValueError("schema desktop v8 à v31 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 4b43d14..eb4c7c7 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, 31)) + supported_versions = tuple(range(8, 32)) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - raise ValueError("schema desktop v8 à v30 requis") + raise ValueError("schema desktop v8 à v31 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 c7d634d..8ea770b 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): - raise ValueError("schema desktop v12-v30 requis") + 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") 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 5965b15..365e0e9 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, 31)) + supported_versions = tuple(range(11, 32)) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - raise ValueError("schema desktop v11-v30 requis") + raise ValueError("schema desktop v11-v31 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 c3014eb..b51cfa3 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): + if version not in (17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31): 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 ded77d8..1474708 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, 31): + if version not in range(8, 32): raise SystemExit( "PC_CATALOG_EXPORT=FAIL " f"schema={version}" diff --git a/tools/export_pc_mobile.py b/tools/export_pc_mobile.py index ce7e5a0..bcc3ec4 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): + if args.version == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31): raise ValueError("mobile V4 exige le schéma desktop v19 ou v20") - supported_versions = range(11, 31) + supported_versions = range(11, 32) 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-v30 requis (v10 accepté pour export V2 explicite)") + raise ValueError("schema desktop v11-v31 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 eb3cfa4..fd770e7 100644 --- a/tools/export_programs.py +++ b/tools/export_programs.py @@ -16,7 +16,7 @@ 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): + 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") programs = [] for row in db.execute( diff --git a/tools/export_session_preparations.py b/tools/export_session_preparations.py index 618532d..2b0a855 100644 --- a/tools/export_session_preparations.py +++ b/tools/export_session_preparations.py @@ -17,7 +17,7 @@ MAX_WITHDRAWALS = 128 def build_export(connection: sqlite3.Connection) -> dict: - supported_versions = (24, 25, 26, 27, 28, 29, 30) + supported_versions = (24, 25, 26, 27, 28, 29, 30, 31) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: raise ValueError("desktop schema v24, v25, v26, v27, v28 or v29 required") deliveries = [] diff --git a/tools/export_training_feedback.py b/tools/export_training_feedback.py index b8ce94a..ecc9dc5 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, 31) + supported_versions = range(15, 32) if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - raise ValueError("schema desktop v15-v30 requis") + raise ValueError("schema desktop v15-v31 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 1794ec9..8913c64 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] != 30: - fail("desktop schema v30 required") + if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31): + fail("desktop schema v30-v31 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 bb67b8a..51393d8 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, 31) + supported_versions = range(18, 32) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - raise ImportFailure("schema desktop v18-v30 requis") + raise ImportFailure("schema desktop v18-v31 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 86b2153..9184a72 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, 31) + supported_versions = range(8, 32) if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions: - fail("schema desktop v8 à v30 requis") + fail("schema desktop v8 à v31 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 06d081f..93947c2 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, 31) + supported_versions = range(8, 32) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - fail("schema desktop v8 à v30 requis") + fail("schema desktop v8 à v31 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 608763c..912fee8 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, 31) + supported_versions = range(12, 32) if con.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - fail("schema desktop v12-v30 requis") + fail("schema desktop v12-v31 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 f0568ee..738c772 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, 31) + supported_versions = range(11, 32) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - raise ImportFailure("schema desktop v11-v30 requis") + raise ImportFailure("schema desktop v11-v31 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 723731a..ca19d35 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): - fail("schema desktop v17-v30 requis") + 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31): + fail("schema desktop v17-v31 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 938cd0d..67cca7e 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, 31): + if version not in range(5, 32): raise ImportFailure( - f"base desktop schema v5 à v30 attendue, version trouvée: {version}" + f"base desktop schema v5 à v31 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): - raise ImportFailure("mobile V4 exige le schéma desktop v19 à v30") + 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 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 68a7869..ffadc20 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, 31) + supported_versions = range(15, 32) if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - fail("schema desktop v15-v30 requis") + fail("schema desktop v15-v31 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 8563934..efb4f4e 100644 --- a/tools/package_sync_candidate.py +++ b/tools/package_sync_candidate.py @@ -19,6 +19,7 @@ RUNTIME_TOOLS = [ "sync_generation_exchange.py", "sleep_diary_exchange.py", "heart_rate_exchange.py", + "session_timeline_exchange.py", "trainlog_sqlite.py", "validate_json.py", "validate_training_knowledge.py", @@ -115,8 +116,8 @@ def main() -> int: ["git", "rev-parse", "HEAD"], cwd=ROOT, text=True ).strip(), "product_version": "0.1.5", - "desktop_schema": 30, - "android_schema": 27, + "desktop_schema": 31, + "android_schema": 28, "protocols": [ "mobile-export-v3", "mobile-history-v4", @@ -132,6 +133,7 @@ def main() -> int: "program-executions-v1", "sleep-diary-v1", "heart-rate-v1", + "session-timeline-v1", ], "entry_points": [ "bin/trainlog", diff --git a/tools/program_execution_exchange.py b/tools/program_execution_exchange.py index c3b938e..ac49e45 100644 --- a/tools/program_execution_exchange.py +++ b/tools/program_execution_exchange.py @@ -87,7 +87,7 @@ 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): + if db.execute("PRAGMA user_version").fetchone()[0] not in (27, 28, 29, 30, 31): fail("desktop schema v27, v28 or v29 required") added = 0 advanced = 0 diff --git a/tools/session_timeline_exchange.py b/tools/session_timeline_exchange.py new file mode 100644 index 0000000..8dbe480 --- /dev/null +++ b/tools/session_timeline_exchange.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +"""Strict Android-owned Trainlog session timeline V1 companion.""" + +import argparse +import json +import re +import sqlite3 +from datetime import datetime +from pathlib import Path + +from trainlog_sqlite import connect_database + +FORMAT = "trainlog-session-timeline" +VERSION = 1 +MAX_BYTES = 4 * 1024 * 1024 +MAX_SESSIONS = 256 +MAX_EXERCISES = 128 +UUID4 = r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}" +SESSION_ID = re.compile(rf"^se_{UUID4}$") +ENTRY_ID = re.compile(rf"^sxe_{UUID4}$") +EXERCISE_ID = re.compile(rf"^ex_{UUID4}$") + + +def fail(message: str) -> None: + raise ValueError(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 timestamp(value: object) -> tuple[str, datetime]: + if not isinstance(value, str) or re.search(r"(?:Z|[+-][0-9]{2}:[0-9]{2})$", value) is None: + fail("invalid session-timeline timestamp") + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as error: + raise ValueError("invalid session-timeline timestamp") from error + if parsed.utcoffset() is None: + fail("session-timeline timestamp lacks offset") + return value, parsed + + +def validate(root: object) -> dict: + if not isinstance(root, dict) or set(root) != { + "format", "version", "generated_at", "sessions" + }: + fail("invalid session-timeline envelope") + if root["format"] != FORMAT or type(root["version"]) is not int or root["version"] != VERSION: + fail("unsupported session-timeline format") + timestamp(root["generated_at"]) + sessions = root["sessions"] + if not isinstance(sessions, list) or len(sessions) > MAX_SESSIONS: + fail("session-timeline session bound exceeded") + seen_sessions = set() + for session in sessions: + if not isinstance(session, dict) or set(session) != { + "session_id", "started_at", "ended_at", "exercises" + }: + fail("invalid session-timeline session") + session_id = session["session_id"] + if ( + not isinstance(session_id, str) + or not SESSION_ID.fullmatch(session_id) + or session_id in seen_sessions + ): + fail("invalid or duplicate session-timeline session identity") + seen_sessions.add(session_id) + _, session_start = timestamp(session["started_at"]) + _, session_end = timestamp(session["ended_at"]) + if session_end < session_start: + fail("invalid session-timeline session interval") + exercises = session["exercises"] + if not isinstance(exercises, list) or len(exercises) > MAX_EXERCISES: + fail("session-timeline exercise bound exceeded") + seen_entries = set() + previous_start = None + for exercise in exercises: + if not isinstance(exercise, dict) or set(exercise) != { + "entry_id", "exercise_id", "started_at", "ended_at" + }: + fail("invalid session-timeline exercise") + entry_id = exercise["entry_id"] + exercise_id = exercise["exercise_id"] + if ( + not isinstance(entry_id, str) + or not ENTRY_ID.fullmatch(entry_id) + or entry_id in seen_entries + or not isinstance(exercise_id, str) + or not EXERCISE_ID.fullmatch(exercise_id) + ): + fail("invalid or duplicate session-timeline exercise identity") + seen_entries.add(entry_id) + _, start = timestamp(exercise["started_at"]) + _, end = timestamp(exercise["ended_at"]) + if start < session_start or end < start or end > session_end: + fail("session-timeline exercise outside session") + if previous_start is not None and start < previous_start: + fail("session-timeline exercises are not ordered") + previous_start = start + return root + + +def load(path: Path) -> dict: + if path.stat().st_size > MAX_BYTES: + fail("session-timeline artifact exceeds 4 MiB") + return validate( + json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=unique_object) + ) + + +def _existing_session(db: sqlite3.Connection, session: dict) -> int: + row = db.execute( + "SELECT id,started_at,ended_at FROM sessions WHERE session_id=?", + (session["session_id"],), + ).fetchone() + if row is None: + fail("session-timeline references missing session") + if row[1] != session["started_at"] or row[2] != session["ended_at"]: + fail("session-timeline session bounds conflict with history") + return int(row[0]) + + +def _same_timing(db: sqlite3.Connection, session_id: str, exercise: dict) -> bool: + row = db.execute( + "SELECT exercise_id,started_at,ended_at FROM session_exercise_timeline " + "WHERE session_id=? AND entry_id=?", + (session_id, exercise["entry_id"]), + ).fetchone() + if row is None: + return False + if tuple(row) != ( + exercise["exercise_id"], + exercise["started_at"], + exercise["ended_at"], + ): + fail("session-timeline identity reused with different content") + return True + + +def apply(db: sqlite3.Connection, root: dict) -> tuple[int, int]: + applied = 0 + unchanged = 0 + for session in root["sessions"]: + session_row_id = _existing_session(db, session) + for exercise in session["exercises"]: + owner = db.execute( + "SELECT e.exercise_id FROM session_exercises se " + "JOIN exercises e ON e.id=se.exercise_row_id " + "WHERE se.session_row_id=? AND se.entry_id=?", + (session_row_id, exercise["entry_id"]), + ).fetchone() + if owner is None or owner[0] != exercise["exercise_id"]: + fail("session-timeline occurrence does not match session history") + if _same_timing(db, session["session_id"], exercise): + unchanged += 1 + continue + db.execute( + "INSERT INTO session_exercise_timeline(" + "session_id,entry_id,exercise_id,started_at,ended_at,imported_at" + ") VALUES(?,?,?,?,?,?)", + ( + session["session_id"], + exercise["entry_id"], + exercise["exercise_id"], + exercise["started_at"], + exercise["ended_at"], + root["generated_at"], + ), + ) + applied += 1 + return applied, unchanged + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("input", type=Path) + parser.add_argument("--database", required=True, type=Path) + 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") + db.execute("BEGIN IMMEDIATE") + apply(db, root) + db.commit() + + +if __name__ == "__main__": + main() diff --git a/tools/sleep_diary_exchange.py b/tools/sleep_diary_exchange.py index 7f550f9..78262b8 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] != 30: - fail("desktop schema v30 required") + if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31): + fail("desktop schema v30-v31 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 6f7763b..bdef365 100644 --- a/tools/sync_generation_exchange.py +++ b/tools/sync_generation_exchange.py @@ -22,6 +22,7 @@ from trainlog_sqlite import connect_database import causal_delete_exchange import execution_draft_exchange import heart_rate_exchange +import session_timeline_exchange import import_equipment_associations import import_equipment_definitions import import_exercise_aliases @@ -83,6 +84,7 @@ ARTIFACTS = ( SUPPORTED = {(row[1], row[2]) for row in ARTIFACTS} | { ("trainlog-program-executions", 1), ("trainlog-heart-rate", 1), + ("trainlog-session-timeline", 1), } @@ -211,7 +213,7 @@ 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) + supported_versions = (24, 25, 26, 27, 28, 29, 30, 31) if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: raise GenerationError("desktop schema v24, v25, v26, v27, v28 or v29 required") @@ -671,6 +673,8 @@ def consume_desktop(database: Path, directory: Path) -> dict: if "sleep-diary" in listed else None) heart_rate = (heart_rate_exchange.load(listed["heart-rate"]) if "heart-rate" in listed else None) + session_timeline = (session_timeline_exchange.load(listed["session-timeline"]) + if "session-timeline" in listed else None) program_executions = ( program_execution_exchange.load(listed["program-executions"]) if "program-executions" in listed else None @@ -716,6 +720,8 @@ def consume_desktop(database: Path, directory: Path) -> dict: sleep_diary_exchange.apply(db, sleep_diary) if heart_rate is not None: heart_rate_exchange.apply(db, heart_rate) + if session_timeline is not None: + session_timeline_exchange.apply(db, session_timeline) if program_executions is not None: program_execution_exchange.apply_executions(db, program_executions) ack = record_consumed(db, manifest, manifest_digest) diff --git a/tui/include/trainlog/database.h b/tui/include/trainlog/database.h index 2400111..3520719 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 30 +#define TRAINLOG_DATABASE_SCHEMA_VERSION 31 typedef struct TrainlogDatabase TrainlogDatabase; diff --git a/tui/meson.build b/tui/meson.build index 9bd137d..218828d 100644 --- a/tui/meson.build +++ b/tui/meson.build @@ -831,6 +831,14 @@ test_schema_v30_migration = executable( ) test('schema_v30_migration', test_schema_v30_migration) +test_schema_v31_migration = executable( + 'test_schema_v31_migration', + 'tests/test_schema_v31_migration.c', + dependencies: trainlog_core_dep, + c_args: strict_c_args, +) +test('schema_v31_migration', test_schema_v31_migration) + test_schema_v7_migration = executable( 'test_schema_v7_migration', 'tests/test_schema_v7_migration.c', @@ -915,6 +923,11 @@ test( find_program('python3'), args: [meson.project_source_root() / 'tests/test_heart_rate_exchange.py'], ) +test( + 'session_timeline_exchange', + find_program('python3'), + args: [meson.project_source_root() / 'tests/test_session_timeline_exchange.py'], +) test( 'session_preparation_export', find_program('python3'), diff --git a/tui/src/database.c b/tui/src/database.c index 85d4e17..229f40c 100644 --- a/tui/src/database.c +++ b/tui/src/database.c @@ -1377,6 +1377,22 @@ static const char *const MIGRATE_V29_TO_V30_SQL = "heart_rate_captures(context_kind,context_id,started_at);" "PRAGMA user_version=30;COMMIT;"; +/* WHY: exercise/cardio correlation requires exact user-action boundaries on + * the canonical desktop history. CONTRACT: v31 imports only Android-recorded + * session occurrence timing facts. INVARIANT: no session or measurement time + * is inferred from order, reps, plans or heart-rate samples. */ +static const char *const MIGRATE_V30_TO_V31_SQL = + "BEGIN IMMEDIATE;" + "CREATE TABLE IF NOT EXISTS session_exercise_timeline(" + "session_id TEXT NOT NULL,entry_id TEXT NOT NULL,exercise_id TEXT NOT NULL," + "started_at TEXT NOT NULL,ended_at TEXT NOT NULL,imported_at TEXT NOT NULL," + "PRIMARY KEY(session_id,entry_id)," + "CHECK(session_id GLOB 'se_*'),CHECK(entry_id GLOB 'sxe_*')," + "CHECK(exercise_id GLOB 'ex_*'));" + "CREATE INDEX IF NOT EXISTS session_exercise_timeline_time ON " + "session_exercise_timeline(started_at,session_id,entry_id);" + "PRAGMA user_version=31;COMMIT;"; + /* 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 @@ -2210,7 +2226,8 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database, } else if (version == 11 || version == 12 || version == 13 || version == 14 || version == 15 || version == 16 || version == 17 || version == 18 || version == 19 || version == 20 || version == 21 || version == 22 || version == 23 || version == 24 || version == 25 || - version == 26 || version == 27 || version == 28 || version == 29 || version == 30) { + version == 26 || version == 27 || version == 28 || version == 29 || version == 30 || + version == 31) { status = TRAINLOG_STATUS_OK; } else { if (version == 1) { @@ -2361,6 +2378,9 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database, if (status == TRAINLOG_STATUS_OK && version < 30) { status = execute_sql(database, MIGRATE_V29_TO_V30_SQL); } + if (status == TRAINLOG_STATUS_OK && version < 31) { + status = execute_sql(database, MIGRATE_V30_TO_V31_SQL); + } if (status == TRAINLOG_STATUS_OK) { status = ensure_v18_ai_draft_publication_state(database); } @@ -2380,7 +2400,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 v30" : "migrate database to schema v30", + version == 0 ? "create schema v31" : "migrate database to schema v31", database->connection, SQLITE_ERROR); (void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL); diff --git a/tui/tests/test_schema_v29_migration.c b/tui/tests/test_schema_v29_migration.c index ac5fb74..4007fab 100644 --- a/tui/tests/test_schema_v29_migration.c +++ b/tui/tests/test_schema_v29_migration.c @@ -38,6 +38,10 @@ int main(void) { production = NULL; CHECK(sqlite3_open(path, &raw) == SQLITE_OK); CHECK(sqlite3_exec(raw, + "DROP TABLE session_exercise_timeline;" + "DROP TABLE heart_rate_rr_intervals;" + "DROP TABLE heart_rate_samples;" + "DROP TABLE heart_rate_captures;" "DROP TABLE sleep_diary_events;DROP TABLE sleep_diary_revisions;" "DROP TABLE sleep_diary_entries;PRAGMA user_version=28;", NULL, @@ -49,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") == 30); + CHECK(scalar(raw, "PRAGMA user_version") == 31); CHECK(scalar(raw, "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN " "('sleep_diary_entries','sleep_diary_revisions','sleep_diary_events'," @@ -74,9 +78,14 @@ int main(void) { production = NULL; CHECK(sqlite3_open(path, &raw) == SQLITE_OK); CHECK(sqlite3_exec(raw, + "DROP TABLE session_exercise_timeline;" + "DROP TABLE heart_rate_rr_intervals;" + "DROP TABLE heart_rate_samples;" + "DROP TABLE heart_rate_captures;" "DROP TABLE sleep_medication_intakes;" "DROP TABLE sleep_medication_revisions;" - "DROP TABLE sleep_medications;", + "DROP TABLE sleep_medications;" + "PRAGMA user_version=29;", NULL, NULL, NULL) == SQLITE_OK); @@ -88,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") == 30); + CHECK(scalar(raw, "PRAGMA user_version") == 31); 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 dd7d1f0..9efde64 100644 --- a/tui/tests/test_schema_v30_migration.c +++ b/tui/tests/test_schema_v30_migration.c @@ -41,6 +41,7 @@ int main(void) { CHECK(sqlite3_open(path, &raw) == SQLITE_OK); CHECK(sqlite3_exec(raw, + "DROP TABLE session_exercise_timeline;" "DROP TABLE heart_rate_rr_intervals;" "DROP TABLE heart_rate_samples;" "DROP TABLE heart_rate_captures;" @@ -51,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 == 30); + CHECK(version == 31); trainlog_database_close(production); production = NULL; diff --git a/tui/tests/test_schema_v31_migration.c b/tui/tests/test_schema_v31_migration.c new file mode 100644 index 0000000..07db381 --- /dev/null +++ b/tui/tests/test_schema_v31_migration.c @@ -0,0 +1,72 @@ +#define _POSIX_C_SOURCE 200809L + +#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-v31-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, + "DROP TABLE session_exercise_timeline;" + "PRAGMA user_version=30;", + 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 == 31); + trainlog_database_close(production); + production = NULL; + + CHECK(sqlite3_open(path, &raw) == SQLITE_OK); + CHECK(scalar(raw, + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' " + "AND name='session_exercise_timeline'") == 1); + CHECK(scalar(raw, + "SELECT COUNT(*) FROM sqlite_master WHERE type='index' " + "AND name='session_exercise_timeline_time'") == 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 v30 to v31 session timeline migration"); + return result; +} diff --git a/tui/tests/test_schema_v7_migration.c b/tui/tests/test_schema_v7_migration.c index 9f9a001..3303657 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 v30: SQLite rc=", - strlen("migrate database to schema v30: SQLite rc=")) == 0); + "migrate database to schema v31: SQLite rc=", + strlen("migrate database to schema v31: 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=31;", NULL, NULL, NULL) == SQLITE_OK); + CHECK(sqlite3_exec(raw, "PRAGMA user_version=32;", 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 31 is newer") != NULL); + CHECK(strstr(diagnostic, "schema version 32 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 425bb25..590bfa4 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 v30") != NULL); + CHECK(strstr(diagnostic, "migrate database to schema v31") != NULL); CHECK(strstr(diagnostic, "performed_sets_v9") != NULL); CHECK(sqlite3_open(path, &raw) == SQLITE_OK);