feat(training): timestamp exercise execution

This commit is contained in:
fy59 2026-09-23 11:32:15 +02:00
parent 1ca354af53
commit dd39c85f94
66 changed files with 1916 additions and 104 deletions

View file

@ -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.

View file

@ -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()

View file

@ -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()
}

View file

@ -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
}

View file

@ -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<String, String> {
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<SessionExerciseTiming> {
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<Any>(OffsetDateTime.now().toString(), sessionRowId))
arrayOf<Any>(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)) {

View file

@ -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. */

View file

@ -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<Pair<Int, SessionExerciseDraft>?>(null) }
var feedbackRevision by remember { mutableStateOf(0) }
var reorderedDuringGesture by remember { mutableStateOf(false) }
var timelineRevision by remember { mutableStateOf(0) }
var pendingExerciseSwitch by remember { mutableStateOf<Pair<String, String>?>(null) }
var pendingFinalizeActiveEntryId by remember { mutableStateOf<String?>(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))
}
},
)
}
}
}

View file

@ -525,4 +525,15 @@
<string name="settings_cardio_configured">Heart-rate sensor configured.</string>
<string name="settings_cardio_forget">Forget sensor</string>
<string name="settings_cardio_forget_description">Stop heart-rate connection and remove the remembered sensor.</string>
<string name="exercise_timeline_start">Start exercise</string>
<string name="exercise_timeline_finish">Finish exercise</string>
<string name="exercise_timeline_completed">Exercise completed</string>
<string name="exercise_timeline_started">%1$s · started</string>
<string name="exercise_timeline_finished">%1$s · finished</string>
<string name="exercise_timeline_switch_title">Switch exercise?</string>
<string name="exercise_timeline_switch_detail">%1$s is still active. Finish it now and then start %2$s?</string>
<string name="exercise_timeline_switch_confirm">Finish and start</string>
<string name="exercise_timeline_finalize_title">An exercise is still active</string>
<string name="exercise_timeline_finalize_detail">%1$s is still active. Finish it now and then save the session?</string>
<string name="exercise_timeline_finalize_confirm">Finish and save</string>
</resources>

View file

@ -535,4 +535,15 @@
<string name="settings_cardio_configured">Capteur cardio configuré.</string>
<string name="settings_cardio_forget">Oublier le capteur</string>
<string name="settings_cardio_forget_description">Arrêter la connexion cardio et supprimer le capteur mémorisé.</string>
<string name="exercise_timeline_start">Démarrer l’exercice</string>
<string name="exercise_timeline_finish">Terminer l’exercice</string>
<string name="exercise_timeline_completed">Exercice terminé</string>
<string name="exercise_timeline_started">%1$s · démarré</string>
<string name="exercise_timeline_finished">%1$s · terminé</string>
<string name="exercise_timeline_switch_title">Changer d’exercice ?</string>
<string name="exercise_timeline_switch_detail">%1$s est encore en cours. Le terminer maintenant puis démarrer %2$s ?</string>
<string name="exercise_timeline_switch_confirm">Terminer et démarrer</string>
<string name="exercise_timeline_finalize_title">Un exercice est encore en cours</string>
<string name="exercise_timeline_finalize_detail">%1$s est encore actif. Le terminer maintenant puis enregistrer la séance ?</string>
<string name="exercise_timeline_finalize_confirm">Terminer et enregistrer</string>
</resources>

View file

@ -66,7 +66,7 @@ class AndroidV10PlanningMigrationTest {
TrainlogRepository(context, name).useForTest { it.listSessions() }
SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
assertEquals(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"))

View file

@ -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") }

View file

@ -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"),

View file

@ -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 ->

View file

@ -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)

View file

@ -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)

View file

@ -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)
}
}
}

View file

@ -328,7 +328,7 @@ class BodyZonesTest {
SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null,
SQLiteDatabase.OPEN_READONLY).use { db ->
db.rawQuery("PRAGMA user_version", null).use { cursor ->
assertTrue(cursor.moveToFirst()); assertEquals(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))

View file

@ -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)

View file

@ -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"),

View file

@ -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)
}
}
}

View file

@ -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()
}
}
}

View file

@ -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())

View file

@ -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())

View file

@ -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` |

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -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()

View file

@ -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())

View file

@ -38,8 +38,8 @@ def digest(value) -> str:
def require_schema(db: sqlite3.Connection) -> None:
version = db.execute("PRAGMA user_version").fetchone()[0]
if version not in (20, 21, 22, 23, 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):

View file

@ -182,8 +182,8 @@ def causal_payload(draft):
def require_schema(connection):
version = connection.execute("PRAGMA user_version").fetchone()[0]
if version not in (19, 20, 21, 22, 23, 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):

View file

@ -12,7 +12,7 @@ from pathlib import Path
from trainlog_sqlite import configure_connection
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 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",

View file

@ -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:

View file

@ -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"))

View file

@ -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(

View file

@ -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"

View file

@ -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 = []

View file

@ -25,7 +25,7 @@ def main():
con.row_factory = sqlite3.Row
try:
version = con.execute("PRAGMA user_version").fetchone()[0]
if version not in (17, 18, 19, 20, 21, 22, 23, 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,

View file

@ -71,7 +71,7 @@ def main():
# CONTRACT: v8 adds only desktop-local custom equipment. The PC
# catalogue artifact is unchanged, but it must read the current
# canonical desktop schema rather than accept a stale pre-v8 database.
if version not in range(8, 31):
if version not in range(8, 32):
raise SystemExit(
"PC_CATALOG_EXPORT=FAIL "
f"schema={version}"

View file

@ -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")

View file

@ -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(

View file

@ -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 = []

View file

@ -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()

View file

@ -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()

View file

@ -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")

View file

@ -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")

View file

@ -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

View file

@ -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}")

View file

@ -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

View file

@ -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"]

View file

@ -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")}

View file

@ -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"]):

View file

@ -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",

View file

@ -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

View file

@ -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()

View file

@ -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")

View file

@ -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)

View file

@ -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;

View file

@ -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'),

View file

@ -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);

View file

@ -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);

View file

@ -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;

View file

@ -0,0 +1,72 @@
#define _POSIX_C_SOURCE 200809L
#include <sqlite3.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#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;
}

View file

@ -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;

View file

@ -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);