feat(cardio): add measured calibration profile

This commit is contained in:
fy59 2026-09-23 13:20:00 +02:00
parent 3ded6ee425
commit 8371d5e16c
74 changed files with 1854 additions and 106 deletions

View file

@ -46,6 +46,8 @@
exact replay is idempotent and identity collision with non-cardio history is exact replay is idempotent and identity collision with non-cardio history is
rejected. rejected.
- Added the first versioned Cardio calibration workflow. Android schema v30 creates one reserved system exercise outside the normal exercise catalogue, starts a real cardio session and HR capture, records only measured observed peak BPM, and requires at least three minutes of recovery. +1/+2/+3 minute recovery facts accept only real samples within a bounded ten-second window; missing signal therefore remains missing instead of being backfilled from a later reconnect. Completed immutable profiles synchronize Android→desktop through `trainlog-cardio-calibrations` V1 into desktop schema v33, where the importer proves peak and recovery points against the synchronized raw HR capture before accepting them.
Development opened after stable v0.1.4 release. Development opened after stable v0.1.4 release.
## 0.1.4 — 2026-09-22 ## 0.1.4 — 2026-09-22

View file

@ -36,7 +36,7 @@ Programs. Sessions → Programmes remains the technical administration/import,
list, detail, archive, and delete surface. list, detail, archive, and delete surface.
Web Exercises administration, the factual Dashboard/Analyse read model, and the Web Exercises administration, the factual Dashboard/Analyse read model, and the
synchronized Sleep Diary workspace are the stable v0.1.4 baseline. Version synchronized Sleep Diary workspace are the stable v0.1.4 baseline. Version
0.1.5 development now has validated persistent CYCPLUS acquisition, the global `♥ BPM` indicator, training exercise timestamps, one-tap Android Sleep capture, and the dedicated synchronized `cardio` session type; Calibration cardio is the current implementation cursor. 0.1.5 development now has validated persistent CYCPLUS acquisition, the global `♥ BPM` indicator, training exercise timestamps, one-tap Android Sleep capture, dedicated synchronized `cardio` sessions, and a versioned measured Cardio calibration; BPM-guided cardio phases are the current implementation cursor.
## Releases ## Releases

View file

@ -225,7 +225,7 @@ internal class AndroidBackupService(
manifest.getInt("version") == VERSION && manifest.getInt("version") == VERSION &&
manifest.getString("package") == context.packageName && manifest.getString("package") == context.packageName &&
manifest.getBoolean("plaintext") && manifest.getBoolean("plaintext") &&
manifest.getInt("schema") in 17..29 manifest.getInt("schema") in 17..30
) { ) {
"Unsupported backup." "Unsupported backup."
} }
@ -319,7 +319,7 @@ internal class AndroidBackupService(
} }
) )
require(db.rawQuery("PRAGMA foreign_key_check", null).use { !it.moveToFirst() }) require(db.rawQuery("PRAGMA foreign_key_check", null).use { !it.moveToFirst() })
require(db.version in 17..29) { "Unsupported Android schema ${db.version}." } require(db.version in 17..30) { "Unsupported Android schema ${db.version}." }
return db.version return db.version
} finally { } finally {
db.close() db.close()

View file

@ -72,6 +72,8 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
Kind("trainlog-program-executions", 1, "program-executions-v1.json", false), Kind("trainlog-program-executions", 1, "program-executions-v1.json", false),
"sleep-diary" to Kind("trainlog-sleep-diary", 1, "sleep-diary-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), "heart-rate" to Kind("trainlog-heart-rate", 1, "heart-rate-v1.json", false),
"cardio-calibrations" to
Kind("trainlog-cardio-calibrations", 1, "cardio-calibrations-v1.json", false),
"session-timeline" to "session-timeline" to
Kind("trainlog-session-timeline", 1, "session-timeline-v1.json", false), Kind("trainlog-session-timeline", 1, "session-timeline-v1.json", false),
) )
@ -435,6 +437,17 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
arrayOf(generationId, captures.getJSONObject(index).getString("capture_id")), arrayOf(generationId, captures.getJSONObject(index).getString("capture_id")),
) )
} }
val calibrations = JSONObject(checkNotNull(artifacts["cardio-calibrations"]))
val calibrationItems = calibrations.getJSONArray("calibrations")
for (index in 0 until calibrationItems.length()) {
db.execSQL(
"INSERT INTO cardio_calibration_generation(generation_id,calibration_id) VALUES(?,?)",
arrayOf(
generationId,
calibrationItems.getJSONObject(index).getString("calibration_id"),
),
)
}
val timeline = JSONObject(checkNotNull(artifacts["session-timeline"])) val timeline = JSONObject(checkNotNull(artifacts["session-timeline"]))
val timelineSessions = timeline.getJSONArray("sessions") val timelineSessions = timeline.getJSONArray("sessions")
for (index in 0 until timelineSessions.length()) { for (index in 0 until timelineSessions.length()) {
@ -1092,6 +1105,14 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
"m.capture_id=heart_rate_captures.capture_id)", "m.capture_id=heart_rate_captures.capture_id)",
arrayOf(ack.getString("consumed_at"), ack.getString("generation_id")), arrayOf(ack.getString("consumed_at"), ack.getString("generation_id")),
) )
db.execSQL(
"UPDATE cardio_calibrations SET acknowledged_at=? WHERE " +
"acknowledged_at IS NULL AND EXISTS(" +
"SELECT 1 FROM cardio_calibration_generation m " +
"WHERE m.generation_id=? AND " +
"m.calibration_id=cardio_calibrations.calibration_id)",
arrayOf(ack.getString("consumed_at"), ack.getString("generation_id")),
)
db.execSQL( db.execSQL(
"UPDATE session_timeline_sessions SET acknowledged_at=? WHERE " + "UPDATE session_timeline_sessions SET acknowledged_at=? WHERE " +
"acknowledged_at IS NULL AND EXISTS(" + "acknowledged_at IS NULL AND EXISTS(" +

View file

@ -17,6 +17,9 @@ import com.labfytools.trainlog.model.ActiveSessionDraft
import com.labfytools.trainlog.model.ActiveSessionTimelineContext import com.labfytools.trainlog.model.ActiveSessionTimelineContext
import com.labfytools.trainlog.model.AiSessionDraft import com.labfytools.trainlog.model.AiSessionDraft
import com.labfytools.trainlog.model.BodyObservationDraft import com.labfytools.trainlog.model.BodyObservationDraft
import com.labfytools.trainlog.model.CardioCalibrationPhase
import com.labfytools.trainlog.model.CardioCalibrationProfile
import com.labfytools.trainlog.model.CardioCalibrationRecoveryPoint
import com.labfytools.trainlog.model.BodyObservationSummary import com.labfytools.trainlog.model.BodyObservationSummary
import com.labfytools.trainlog.model.ExerciseDataFields import com.labfytools.trainlog.model.ExerciseDataFields
import com.labfytools.trainlog.model.ExerciseProfile import com.labfytools.trainlog.model.ExerciseProfile
@ -65,6 +68,7 @@ import java.io.StringReader
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import java.security.MessageDigest import java.security.MessageDigest
import java.time.Duration
import java.time.LocalDate import java.time.LocalDate
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.time.temporal.WeekFields import java.time.temporal.WeekFields
@ -530,6 +534,13 @@ class TrainlogRepository(
data class DatabaseError(val message: String) : SessionExerciseTimingResult data class DatabaseError(val message: String) : SessionExerciseTimingResult
} }
sealed interface CardioCalibrationResult {
data class Applied(val profile: CardioCalibrationProfile) : CardioCalibrationResult
data class Invalid(val message: String) : CardioCalibrationResult
data object Conflict : CardioCalibrationResult
data class DatabaseError(val message: String) : CardioCalibrationResult
}
private fun legacySessionTypeWire(type: SessionType): String = private fun legacySessionTypeWire(type: SessionType): String =
if (type == SessionType.MAX_TEST) SessionType.MAX_TEST.wireValue else SessionType.TRAINING.wireValue if (type == SessionType.MAX_TEST) SessionType.MAX_TEST.wireValue else SessionType.TRAINING.wireValue
@ -1675,6 +1686,445 @@ class TrainlogRepository(
} }
} }
private fun readCardioCalibrationProfile(
db: SQLiteDatabase,
calibrationId: String,
): CardioCalibrationProfile? {
val row =
db.rawQuery(
"SELECT calibration_id,protocol_version,session_id,entry_id,phase,started_at," +
"phase_started_at,effort_end_at,ended_at,heart_rate_capture_id,observed_peak_bpm " +
"FROM cardio_calibrations WHERE calibration_id=?",
arrayOf(calibrationId),
).use { cursor ->
if (!cursor.moveToFirst()) null
else arrayOf<Any?>(
cursor.getString(0),
cursor.getInt(1),
cursor.getString(2),
cursor.getString(3),
cursor.getString(4),
cursor.getString(5),
cursor.getString(6),
if (cursor.isNull(7)) null else cursor.getString(7),
if (cursor.isNull(8)) null else cursor.getString(8),
if (cursor.isNull(9)) null else cursor.getString(9),
if (cursor.isNull(10)) null else cursor.getInt(10),
)
} ?: return null
val recovery =
db.rawQuery(
"SELECT target_offset_seconds,observed_at,bpm FROM cardio_calibration_recovery " +
"WHERE calibration_id=? ORDER BY target_offset_seconds",
arrayOf(calibrationId),
).use { cursor ->
buildList {
while (cursor.moveToNext()) {
add(
CardioCalibrationRecoveryPoint(
targetOffsetSeconds = cursor.getInt(0),
observedAt = cursor.getString(1),
bpm = cursor.getInt(2),
),
)
}
}
}
return CardioCalibrationProfile(
calibrationId = row[0] as String,
protocolVersion = row[1] as Int,
sessionId = row[2] as String,
entryId = row[3] as String,
phase = CardioCalibrationPhase.fromWire(row[4] as String),
startedAt = row[5] as String,
phaseStartedAt = row[6] as String,
effortEndAt = row[7] as String?,
endedAt = row[8] as String?,
heartRateCaptureId = row[9] as String?,
observedPeakBpm = row[10] as Int?,
recovery = recovery,
)
}
fun activeCardioCalibration(): CardioCalibrationProfile? {
val db = database.readableDatabase
val id =
db.rawQuery(
"SELECT calibration_id FROM cardio_calibrations WHERE active_slot=1 LIMIT 1",
null,
).use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null }
?: return null
return readCardioCalibrationProfile(db, id)
}
fun listCompletedCardioCalibrations(limit: Int = 32): List<CardioCalibrationProfile> {
require(limit in 1..128)
val db = database.readableDatabase
val ids =
db.rawQuery(
"SELECT calibration_id FROM cardio_calibrations WHERE phase='completed' " +
"ORDER BY ended_at DESC,calibration_id DESC LIMIT ?",
arrayOf(limit.toString()),
).use { cursor ->
buildList {
while (cursor.moveToNext()) add(cursor.getString(0))
}
}
return ids.mapNotNull { readCardioCalibrationProfile(db, it) }
}
internal fun buildCardioCalibrationsV1Json(): String {
val db = database.readableDatabase
val calibrations = JSONArray()
db.rawQuery(
"SELECT c.calibration_id FROM cardio_calibrations c " +
"WHERE c.phase='completed' AND c.acknowledged_at IS NULL AND NOT EXISTS(" +
"SELECT 1 FROM cardio_calibration_generation m " +
"JOIN sync_generations g ON g.generation_id=m.generation_id " +
"WHERE m.calibration_id=c.calibration_id AND " +
"g.status IN('captured','published','waiting_acknowledgement')) " +
"ORDER BY c.ended_at,c.calibration_id LIMIT ?",
arrayOf(MAX_CARDIO_CALIBRATIONS_PER_GENERATION.toString()),
).use { cursor ->
while (cursor.moveToNext()) {
val profile = checkNotNull(readCardioCalibrationProfile(db, cursor.getString(0)))
val recovery = JSONArray()
profile.recovery.forEach { point ->
recovery.put(
JSONObject()
.put("target_offset_seconds", point.targetOffsetSeconds)
.put("observed_at", point.observedAt)
.put("bpm", point.bpm),
)
}
calibrations.put(
JSONObject()
.put("calibration_id", profile.calibrationId)
.put("protocol_version", profile.protocolVersion)
.put("session_id", profile.sessionId)
.put("entry_id", profile.entryId)
.put("started_at", profile.startedAt)
.put("effort_end_at", checkNotNull(profile.effortEndAt))
.put("ended_at", checkNotNull(profile.endedAt))
.put("heart_rate_capture_id", checkNotNull(profile.heartRateCaptureId))
.put("observed_peak_bpm", checkNotNull(profile.observedPeakBpm))
.put("recovery", recovery),
)
}
}
return JSONObject()
.put("format", "trainlog-cardio-calibrations")
.put("version", 1)
.put("generated_at", OffsetDateTime.now().toString())
.put("calibrations", calibrations)
.toString()
}
fun startCardioCalibration(
startedAt: String = OffsetDateTime.now().toString(),
): CardioCalibrationResult {
if (TrainlogTimestamp.parse(startedAt) == null) {
return CardioCalibrationResult.Invalid("Invalid calibration start timestamp.")
}
if (activeCardioCalibration() != null || loadActiveSessionDraft() !is ActiveDraftLoadResult.None) {
return CardioCalibrationResult.Conflict
}
val exerciseRow =
findExerciseRow(
database.readableDatabase,
"exercise_id=?",
arrayOf(CARDIO_CALIBRATION_EXERCISE_ID),
) ?: return CardioCalibrationResult.DatabaseError("Calibration exercise is unavailable.")
val exercise =
ExerciseProfile(
exerciseId = exerciseRow.exerciseId,
name = exerciseRow.name,
normalizedName = exerciseRow.normalizedName,
recordingMode = exerciseRow.recordingMode,
trackingMode = exerciseRow.trackingMode,
dataFields = exerciseRow.dataFields,
)
val entry = SessionExerciseDraft(exercise = exercise)
when (
saveActiveSessionDraft(
ActiveSessionDraft(
startedAt = startedAt,
exercises = listOf(entry),
sessionType = SessionType.CARDIO,
),
)
) {
ActiveDraftMutationResult.Saved -> Unit
is ActiveDraftMutationResult.Error ->
return CardioCalibrationResult.DatabaseError("Calibration draft creation failed.")
}
val loaded = loadActiveSessionDraft() as? ActiveDraftLoadResult.Loaded
?: return CardioCalibrationResult.DatabaseError("Calibration draft is unavailable.")
val sessionId = loaded.draft.sessionId
?: return CardioCalibrationResult.DatabaseError("Calibration session identity is missing.")
val actualStartedAt = loaded.draft.startedAt
?: return CardioCalibrationResult.DatabaseError("Calibration start timestamp is missing.")
val actualEntry = loaded.draft.exercises.singleOrNull()
?: return CardioCalibrationResult.DatabaseError("Calibration occurrence is missing.")
when (startActiveSessionExercise(actualEntry.entryId, actualStartedAt)) {
is SessionExerciseTimingResult.Started -> Unit
else -> {
discardActiveSessionDraft()
return CardioCalibrationResult.DatabaseError("Calibration timing could not start.")
}
}
val calibrationId = "cal_" + UUID.randomUUID()
val db = database.writableDatabase
return try {
db.execSQL(
"INSERT INTO cardio_calibrations(" +
"calibration_id,protocol_version,session_id,entry_id,phase,started_at," +
"phase_started_at,effort_end_at,ended_at,heart_rate_capture_id," +
"observed_peak_bpm,active_slot,acknowledged_at) " +
"VALUES(?,?,?,?,?,?,?,NULL,NULL,NULL,NULL,1,NULL)",
arrayOf<Any?>(
calibrationId,
CARDIO_CALIBRATION_PROTOCOL_VERSION,
sessionId,
actualEntry.entryId,
CardioCalibrationPhase.WARMUP.wireValue,
actualStartedAt,
actualStartedAt,
),
)
CardioCalibrationResult.Applied(
checkNotNull(readCardioCalibrationProfile(db, calibrationId)),
)
} catch (error: Exception) {
discardActiveSessionDraft()
CardioCalibrationResult.DatabaseError(
error.message ?: "Calibration creation failed.",
)
}
}
fun advanceCardioCalibrationPhase(
at: String = OffsetDateTime.now().toString(),
): CardioCalibrationResult {
if (TrainlogTimestamp.parse(at) == null) {
return CardioCalibrationResult.Invalid("Invalid calibration phase timestamp.")
}
val current = activeCardioCalibration() ?: return CardioCalibrationResult.Invalid("No calibration is active.")
val next =
when (current.phase) {
CardioCalibrationPhase.WARMUP -> CardioCalibrationPhase.PROGRESSIVE
CardioCalibrationPhase.PROGRESSIVE -> CardioCalibrationPhase.HIGH_EFFORT
else -> return CardioCalibrationResult.Invalid("Calibration phase cannot advance.")
}
if (TrainlogTimestamp.parse(at)!! < TrainlogTimestamp.parse(current.phaseStartedAt)!!) {
return CardioCalibrationResult.Invalid("Calibration phase cannot move backwards in time.")
}
val db = database.writableDatabase
return try {
db.execSQL(
"UPDATE cardio_calibrations SET phase=?,phase_started_at=? " +
"WHERE calibration_id=? AND active_slot=1",
arrayOf(next.wireValue, at, current.calibrationId),
)
CardioCalibrationResult.Applied(
checkNotNull(readCardioCalibrationProfile(db, current.calibrationId)),
)
} catch (error: Exception) {
CardioCalibrationResult.DatabaseError(error.message ?: "Calibration phase update failed.")
}
}
fun markCardioCalibrationEffortEnd(
at: String = OffsetDateTime.now().toString(),
): CardioCalibrationResult {
if (TrainlogTimestamp.parse(at) == null) {
return CardioCalibrationResult.Invalid("Invalid effort-end timestamp.")
}
val current = activeCardioCalibration() ?: return CardioCalibrationResult.Invalid("No calibration is active.")
if (current.phase != CardioCalibrationPhase.HIGH_EFFORT) {
return CardioCalibrationResult.Invalid("High-effort phase is not active.")
}
if (current.observedPeakBpm == null || current.heartRateCaptureId == null) {
return CardioCalibrationResult.Invalid("No measured heart-rate sample is available.")
}
if (TrainlogTimestamp.parse(at)!! < TrainlogTimestamp.parse(current.phaseStartedAt)!!) {
return CardioCalibrationResult.Invalid("Effort cannot end before its phase starts.")
}
val db = database.writableDatabase
return try {
db.execSQL(
"UPDATE cardio_calibrations SET phase='recovery',phase_started_at=?,effort_end_at=? " +
"WHERE calibration_id=? AND active_slot=1",
arrayOf(at, at, current.calibrationId),
)
CardioCalibrationResult.Applied(
checkNotNull(readCardioCalibrationProfile(db, current.calibrationId)),
)
} catch (error: Exception) {
CardioCalibrationResult.DatabaseError(error.message ?: "Effort-end update failed.")
}
}
fun finishCardioCalibration(
endedAt: String = OffsetDateTime.now().toString(),
): CardioCalibrationResult {
if (TrainlogTimestamp.parse(endedAt) == null) {
return CardioCalibrationResult.Invalid("Invalid calibration end timestamp.")
}
val current = activeCardioCalibration() ?: return CardioCalibrationResult.Invalid("No calibration is active.")
if (
current.phase != CardioCalibrationPhase.RECOVERY ||
current.observedPeakBpm == null ||
current.heartRateCaptureId == null
) {
return CardioCalibrationResult.Invalid("Calibration is not ready to finish.")
}
val effortEnd =
current.effortEndAt?.let { runCatching { OffsetDateTime.parse(it) }.getOrNull() }
?: return CardioCalibrationResult.Invalid("Calibration effort end is missing.")
val parsedEnd = OffsetDateTime.parse(endedAt)
if (parsedEnd.isBefore(effortEnd.plusSeconds(CARDIO_CALIBRATION_MIN_RECOVERY_SECONDS))) {
return CardioCalibrationResult.Invalid("Calibration recovery must last at least three minutes.")
}
val timing =
listActiveSessionExerciseTimings().singleOrNull {
it.entryId == current.entryId && it.active
} ?: return CardioCalibrationResult.Invalid("Calibration exercise is not active.")
val durationSeconds =
Duration.between(OffsetDateTime.parse(timing.startedAt), OffsetDateTime.parse(endedAt)).seconds
if (durationSeconds !in 1..MAX_PLAN_DURATION_SECONDS.toLong()) {
return CardioCalibrationResult.Invalid("Calibration duration is invalid.")
}
val loaded = loadActiveSessionDraft() as? ActiveDraftLoadResult.Loaded
?: return CardioCalibrationResult.Invalid("Calibration draft is unavailable.")
if (loaded.draft.sessionId != current.sessionId || loaded.draft.sessionType != SessionType.CARDIO) {
return CardioCalibrationResult.Conflict
}
val updatedEntries = loaded.draft.exercises.map { occurrence ->
if (occurrence.entryId == current.entryId) {
occurrence.copy(continuousDurationSeconds = durationSeconds.toInt())
} else occurrence
}
when (saveActiveSessionDraft(loaded.draft.copy(exercises = updatedEntries))) {
ActiveDraftMutationResult.Saved -> Unit
is ActiveDraftMutationResult.Error ->
return CardioCalibrationResult.DatabaseError("Calibration duration could not be saved.")
}
when (finishActiveSessionExercise(current.entryId, endedAt)) {
is SessionExerciseTimingResult.Finished -> Unit
else -> return CardioCalibrationResult.DatabaseError("Calibration timing could not finish.")
}
when (finalizeActiveSessionDraft()) {
is FinalizeActiveDraftResult.Saved -> Unit
else -> return CardioCalibrationResult.DatabaseError("Calibration session could not finalize.")
}
val db = database.writableDatabase
return try {
db.execSQL(
"UPDATE cardio_calibrations SET phase='completed',phase_started_at=?,ended_at=?," +
"active_slot=NULL WHERE calibration_id=? AND active_slot=1",
arrayOf(endedAt, endedAt, current.calibrationId),
)
CardioCalibrationResult.Applied(
checkNotNull(readCardioCalibrationProfile(db, current.calibrationId)),
)
} catch (error: Exception) {
CardioCalibrationResult.DatabaseError(error.message ?: "Calibration completion failed.")
}
}
fun abortCardioCalibration(
endedAt: String = OffsetDateTime.now().toString(),
): CardioCalibrationResult {
if (TrainlogTimestamp.parse(endedAt) == null) {
return CardioCalibrationResult.Invalid("Invalid calibration abort timestamp.")
}
val current = activeCardioCalibration() ?: return CardioCalibrationResult.Invalid("No calibration is active.")
val db = database.writableDatabase
return try {
db.execSQL(
"UPDATE cardio_calibrations SET phase='aborted',phase_started_at=?,ended_at=?," +
"heart_rate_capture_id=NULL,active_slot=NULL WHERE calibration_id=? AND active_slot=1",
arrayOf(endedAt, endedAt, current.calibrationId),
)
discardActiveSessionDraft()
CardioCalibrationResult.Applied(
checkNotNull(readCardioCalibrationProfile(db, current.calibrationId)),
)
} catch (error: Exception) {
CardioCalibrationResult.DatabaseError(error.message ?: "Calibration abort failed.")
}
}
private fun updateActiveCardioCalibrationFromMeasurement(
captureId: String,
observedAt: String,
measurement: ParsedHeartRateMeasurement,
) {
val observed = runCatching { OffsetDateTime.parse(observedAt) }.getOrNull() ?: return
val db = database.writableDatabase
db.beginTransaction()
try {
val row =
db.rawQuery(
"SELECT calibration_id,phase,effort_end_at,heart_rate_capture_id,observed_peak_bpm " +
"FROM cardio_calibrations WHERE active_slot=1 LIMIT 1",
null,
).use { cursor ->
if (!cursor.moveToFirst()) null
else arrayOf<Any?>(
cursor.getString(0),
cursor.getString(1),
if (cursor.isNull(2)) null else cursor.getString(2),
if (cursor.isNull(3)) null else cursor.getString(3),
if (cursor.isNull(4)) null else cursor.getInt(4),
)
} ?: return
val calibrationId = row[0] as String
val phase = CardioCalibrationPhase.fromWire(row[1] as String)
val existingCapture = row[3] as String?
if (existingCapture != null && existingCapture != captureId) return
if (existingCapture == null) {
db.execSQL(
"UPDATE cardio_calibrations SET heart_rate_capture_id=? WHERE calibration_id=?",
arrayOf(captureId, calibrationId),
)
}
if (phase in setOf(
CardioCalibrationPhase.WARMUP,
CardioCalibrationPhase.PROGRESSIVE,
CardioCalibrationPhase.HIGH_EFFORT,
)
) {
val peak = row[4] as Int?
if (peak == null || measurement.bpm > peak) {
db.execSQL(
"UPDATE cardio_calibrations SET observed_peak_bpm=? WHERE calibration_id=?",
arrayOf<Any?>(measurement.bpm, calibrationId),
)
}
} else if (phase == CardioCalibrationPhase.RECOVERY) {
val effortEnd = OffsetDateTime.parse(row[2] as String? ?: return)
for (offset in CARDIO_CALIBRATION_RECOVERY_OFFSETS) {
val target = effortEnd.plusSeconds(offset.toLong())
val latestAccepted =
target.plusSeconds(CARDIO_CALIBRATION_RECOVERY_SAMPLE_MAX_LAG_SECONDS)
if (!observed.isBefore(target) && !observed.isAfter(latestAccepted)) {
db.execSQL(
"INSERT OR IGNORE INTO cardio_calibration_recovery(" +
"calibration_id,target_offset_seconds,observed_at,bpm) VALUES(?,?,?,?)",
arrayOf<Any?>(calibrationId, offset, observedAt, measurement.bpm),
)
}
}
}
db.setTransactionSuccessful()
} finally {
db.endTransaction()
}
}
internal fun recordLiveHeartRateForActiveSession( internal fun recordLiveHeartRateForActiveSession(
sensorName: String?, sensorName: String?,
observedAt: String, observedAt: String,
@ -1714,7 +2164,8 @@ class TrainlogRepository(
} }
} }
return appendHeartRateSample( val result =
appendHeartRateSample(
captureId = current.captureId, captureId = current.captureId,
observedAt = observedAt, observedAt = observedAt,
bpm = measurement.bpm, bpm = measurement.bpm,
@ -1725,6 +2176,16 @@ class TrainlogRepository(
HeartRateRrInterval(index, value) HeartRateRrInterval(index, value)
}, },
) )
if (result is HeartRateMutationResult.Applied && contextKind == HeartRateContextKind.CARDIO) {
runCatching {
updateActiveCardioCalibrationFromMeasurement(
current.captureId,
observedAt,
measurement,
)
}
}
return result
} }
private fun heartRateContextIdValid(kind: HeartRateContextKind, value: String): Boolean = private fun heartRateContextIdValid(kind: HeartRateContextKind, value: String): Boolean =
@ -2221,6 +2682,7 @@ class TrainlogRepository(
artifacts["program-executions"] = buildProgramExecutionsV1Json() artifacts["program-executions"] = buildProgramExecutionsV1Json()
artifacts["sleep-diary"] = buildSleepDiaryV1Json() artifacts["sleep-diary"] = buildSleepDiaryV1Json()
artifacts["heart-rate"] = buildHeartRateV1Json() artifacts["heart-rate"] = buildHeartRateV1Json()
artifacts["cardio-calibrations"] = buildCardioCalibrationsV1Json()
artifacts["session-timeline"] = buildSessionTimelineV1Json() artifacts["session-timeline"] = buildSessionTimelineV1Json()
artifacts artifacts
} }
@ -2746,6 +3208,8 @@ class TrainlogRepository(
val normalizedPrefix = normalizeName(query) val normalizedPrefix = normalizeName(query)
val selection = mutableListOf<String>() val selection = mutableListOf<String>()
val arguments = mutableListOf<String>() val arguments = mutableListOf<String>()
selection += "e.exercise_id<>?"
arguments += CARDIO_CALIBRATION_EXERCISE_ID
if (normalizedPrefix.isNotEmpty()) { if (normalizedPrefix.isNotEmpty()) {
selection += "e.normalized_name LIKE ? ESCAPE '\\'" selection += "e.normalized_name LIKE ? ESCAPE '\\'"
arguments += normalizedPrefix arguments += normalizedPrefix
@ -3078,8 +3542,9 @@ class TrainlogRepository(
"SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields," + "SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields," +
"e.load_semantics,e.machine_variant,e.machine_provenance,e.scientific_profile_id," + "e.load_semantics,e.machine_variant,e.machine_provenance,e.scientific_profile_id," +
"e.science_state,e.legacy_equipment_id,s.revision_id,s.parent_revision_id,s.legacy_seed " + "e.science_state,e.legacy_equipment_id,s.revision_id,s.parent_revision_id,s.legacy_seed " +
"FROM exercises e JOIN exercise_profile_state s ON s.exercise_row_id=e.id ORDER BY e.exercise_id", "FROM exercises e JOIN exercise_profile_state s ON s.exercise_row_id=e.id " +
null, "WHERE e.exercise_id<>? ORDER BY e.exercise_id",
arrayOf(CARDIO_CALIBRATION_EXERCISE_ID),
).use { c -> ).use { c ->
while (c.moveToNext()) { while (c.moveToNext()) {
check(c.getType(3)==Cursor.FIELD_TYPE_INTEGER && c.getType(12)==Cursor.FIELD_TYPE_INTEGER && check(c.getType(3)==Cursor.FIELD_TYPE_INTEGER && c.getType(12)==Cursor.FIELD_TYPE_INTEGER &&
@ -4876,6 +5341,11 @@ class TrainlogRepository(
) )
val suppliedExerciseId = item.getString("exercise_id") val suppliedExerciseId = item.getString("exercise_id")
if (suppliedExerciseId == CARDIO_CALIBRATION_EXERCISE_ID) {
return PcCatalogImportResult.Invalid(
"Reserved cardio calibration exercise is not owned by the PC catalog.",
)
}
/* CONTRACT: a peer which has not yet consumed the companion /* CONTRACT: a peer which has not yet consumed the companion
* may resend a retired catalogue ID; resolve it before lookup * may resend a retired catalogue ID; resolve it before lookup
* so the old row can never be resurrected. */ * so the old row can never be resurrected. */
@ -9862,6 +10332,22 @@ class TrainlogRepository(
allowTargetOnly: Boolean = false, allowTargetOnly: Boolean = false,
): Boolean { ): Boolean {
if (!validateSessionPlan(draft)) return false if (!validateSessionPlan(draft)) return false
if (
allowTargetOnly &&
sessionType == SessionType.CARDIO &&
draft.exercise.exerciseId == CARDIO_CALIBRATION_EXERCISE_ID &&
draft.exercise.recordingMode == RecordingMode.CONTINUOUS &&
draft.exercise.trackingMode == TrackingMode.DURATION &&
draft.exercise.dataFields == 0 &&
draft.plan == null &&
draft.sets.isEmpty() &&
draft.continuousDurationSeconds == 0 &&
draft.speedKmh == null &&
draft.distanceKm == null &&
draft.maxWeightKg == null
) {
return true
}
val maxWeight = draft.maxWeightKg val maxWeight = draft.maxWeightKg
if (maxWeight != null) { if (maxWeight != null) {
/* INVARIANT: max is a first-class result owned by the movement /* INVARIANT: max is a first-class result owned by the movement
@ -10674,6 +11160,14 @@ 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_HEART_RATE_SENSOR_NAME_UTF8_BYTES = 160
private const val MAX_SESSION_TIMELINE_SESSIONS_PER_GENERATION = 256 private const val MAX_SESSION_TIMELINE_SESSIONS_PER_GENERATION = 256
private const val MAX_SESSION_TIMELINE_EXERCISES_PER_SESSION = 128 private const val MAX_SESSION_TIMELINE_EXERCISES_PER_SESSION = 128
private const val CARDIO_CALIBRATION_PROTOCOL_VERSION = 1
private const val CARDIO_CALIBRATION_EXERCISE_ID =
"ex_ca1b4a7e-1c2d-4f00-8a11-000000000001"
private const val CARDIO_CALIBRATION_EXERCISE_NAME = "Calibration cardio"
private const val MAX_CARDIO_CALIBRATIONS_PER_GENERATION = 128
private val CARDIO_CALIBRATION_RECOVERY_OFFSETS = intArrayOf(60, 120, 180)
private const val CARDIO_CALIBRATION_MIN_RECOVERY_SECONDS = 180L
private const val CARDIO_CALIBRATION_RECOVERY_SAMPLE_MAX_LAG_SECONDS = 10L
private const val MAX_AI_DRAFT_ENTRIES = 64 private const val MAX_AI_DRAFT_ENTRIES = 64
private const val MAX_AI_TARGET_SETS = 99 private const val MAX_AI_TARGET_SETS = 99
private const val MAX_AI_TARGET_REPS = 999 private const val MAX_AI_TARGET_REPS = 999
@ -10692,7 +11186,7 @@ private class TrainlogDatabaseHelper(
appContext, appContext,
databaseName, databaseName,
null, null,
29, 30,
) { ) {
override fun onConfigure( override fun onConfigure(
db: SQLiteDatabase, db: SQLiteDatabase,
@ -10748,6 +11242,8 @@ private class TrainlogDatabaseHelper(
createSleepDiaryTables(db) createSleepDiaryTables(db)
createHeartRateTables(db) createHeartRateTables(db)
createSessionTimelineTables(db) createSessionTimelineTables(db)
createCardioCalibrationTables(db)
ensureCardioCalibrationExercise(db)
seedEquipment(db) seedEquipment(db)
} }
@ -11004,6 +11500,16 @@ private class TrainlogDatabaseHelper(
) )
version = 29 version = 29
} }
if (version < 30 && newVersion >= 30) {
/* WHY: calibration metadata must be versioned separately from the
* authoritative raw heart-rate curve. CONTRACT: v30 adds only the
* calibration profile/recovery/publication domain and one reserved
* continuous exercise identity. INVARIANT: no existing workout,
* heart-rate sample, or session timing is rewritten. */
createCardioCalibrationTables(db)
ensureCardioCalibrationExercise(db)
version = 30
}
if (version != newVersion) { if (version != newVersion) {
error( error(
@ -11142,6 +11648,85 @@ private class TrainlogDatabaseHelper(
PRIMARY KEY(generation_id,entry_id))""".trimIndent()) PRIMARY KEY(generation_id,entry_id))""".trimIndent())
} }
private fun createCardioCalibrationTables(db: SQLiteDatabase) {
db.execSQL(
"""CREATE TABLE IF NOT EXISTS cardio_calibrations(
calibration_id TEXT PRIMARY KEY,
protocol_version INTEGER NOT NULL CHECK(protocol_version=1),
session_id TEXT NOT NULL UNIQUE,
entry_id TEXT NOT NULL,
phase TEXT NOT NULL
CHECK(phase IN('warmup','progressive','high_effort','recovery','completed','aborted')),
started_at TEXT NOT NULL,
phase_started_at TEXT NOT NULL,
effort_end_at TEXT,
ended_at TEXT,
heart_rate_capture_id TEXT,
observed_peak_bpm INTEGER CHECK(observed_peak_bpm IS NULL OR observed_peak_bpm BETWEEN 0 AND 65535),
active_slot INTEGER UNIQUE CHECK(active_slot IS NULL OR active_slot=1),
acknowledged_at TEXT,
CHECK(calibration_id GLOB 'cal_*'),
CHECK(session_id GLOB 'se_*'),
CHECK(entry_id GLOB 'sxe_*'),
CHECK(heart_rate_capture_id IS NULL OR heart_rate_capture_id GLOB 'hrc_*'),
CHECK((phase IN('completed','aborted'))=(ended_at IS NOT NULL)),
CHECK(phase='aborted' OR
(phase IN('recovery','completed') AND effort_end_at IS NOT NULL) OR
(phase IN('warmup','progressive','high_effort') AND effort_end_at IS NULL))
)""".trimIndent(),
)
db.execSQL(
"""CREATE TABLE IF NOT EXISTS cardio_calibration_recovery(
calibration_id TEXT NOT NULL
REFERENCES cardio_calibrations(calibration_id) ON DELETE CASCADE,
target_offset_seconds INTEGER NOT NULL CHECK(target_offset_seconds IN(60,120,180)),
observed_at TEXT NOT NULL,
bpm INTEGER NOT NULL CHECK(bpm BETWEEN 0 AND 65535),
PRIMARY KEY(calibration_id,target_offset_seconds)
)""".trimIndent(),
)
db.execSQL(
"""CREATE TABLE IF NOT EXISTS cardio_calibration_generation(
generation_id TEXT NOT NULL
REFERENCES sync_generations(generation_id) ON DELETE RESTRICT,
calibration_id TEXT NOT NULL
REFERENCES cardio_calibrations(calibration_id) ON DELETE RESTRICT,
PRIMARY KEY(generation_id,calibration_id)
)""".trimIndent(),
)
db.execSQL(
"CREATE INDEX IF NOT EXISTS cardio_calibration_session " +
"ON cardio_calibrations(session_id,started_at);",
)
}
private fun ensureCardioCalibrationExercise(db: SQLiteDatabase) {
db.execSQL(
"""INSERT OR IGNORE INTO exercises(
exercise_id,name,normalized_name,recording_mode,tracking_mode,data_fields
) VALUES(?,?,?,'continuous','duration',0)""".trimIndent(),
arrayOf(
CARDIO_CALIBRATION_EXERCISE_ID,
CARDIO_CALIBRATION_EXERCISE_NAME,
"calibration cardio",
),
)
val valid =
db.rawQuery(
"SELECT name,normalized_name,recording_mode,tracking_mode,data_fields " +
"FROM exercises WHERE exercise_id=?",
arrayOf(CARDIO_CALIBRATION_EXERCISE_ID),
).use { cursor ->
cursor.moveToFirst() &&
cursor.getString(0) == CARDIO_CALIBRATION_EXERCISE_NAME &&
cursor.getString(1) == "calibration cardio" &&
cursor.getString(2) == "continuous" &&
cursor.getString(3) == "duration" &&
cursor.getInt(4) == 0
}
check(valid) { "Reserved cardio calibration exercise conflicts with existing catalogue data." }
}
private fun createHeartRateTables(db: SQLiteDatabase) { private fun createHeartRateTables(db: SQLiteDatabase) {
db.execSQL( db.execSQL(
"""CREATE TABLE IF NOT EXISTS heart_rate_captures( """CREATE TABLE IF NOT EXISTS heart_rate_captures(

View file

@ -0,0 +1,37 @@
package com.labfytools.trainlog.model
enum class CardioCalibrationPhase(val wireValue: String) {
WARMUP("warmup"),
PROGRESSIVE("progressive"),
HIGH_EFFORT("high_effort"),
RECOVERY("recovery"),
COMPLETED("completed"),
ABORTED("aborted");
companion object {
fun fromWire(value: String): CardioCalibrationPhase =
entries.firstOrNull { it.wireValue == value }
?: error("Unknown cardio calibration phase: $value")
}
}
data class CardioCalibrationRecoveryPoint(
val targetOffsetSeconds: Int,
val observedAt: String,
val bpm: Int,
)
data class CardioCalibrationProfile(
val calibrationId: String,
val protocolVersion: Int,
val sessionId: String,
val entryId: String,
val phase: CardioCalibrationPhase,
val startedAt: String,
val phaseStartedAt: String,
val effortEndAt: String?,
val endedAt: String?,
val heartRateCaptureId: String?,
val observedPeakBpm: Int?,
val recovery: List<CardioCalibrationRecoveryPoint>,
)

View file

@ -69,6 +69,7 @@ fun AndroidAppShell(
AppRoute.SessionEditor -> R.string.route_session_editor; AppRoute.SessionGenerator -> R.string.route_session_generator AppRoute.SessionEditor -> R.string.route_session_editor; AppRoute.SessionGenerator -> R.string.route_session_generator
AppRoute.AiSessionDrafts -> R.string.route_ai_drafts; AppRoute.CompletedSessions -> R.string.route_completed_sessions AppRoute.AiSessionDrafts -> R.string.route_ai_drafts; AppRoute.CompletedSessions -> R.string.route_completed_sessions
AppRoute.Programs -> R.string.route_programs; is AppRoute.ProgramDetail -> R.string.route_program_detail AppRoute.Programs -> R.string.route_programs; is AppRoute.ProgramDetail -> R.string.route_program_detail
AppRoute.CardioCalibration -> R.string.route_cardio_calibration
is AppRoute.SessionDetail -> R.string.route_session_detail; is AppRoute.SessionCorrection -> R.string.route_session_correction is AppRoute.SessionDetail -> R.string.route_session_detail; is AppRoute.SessionCorrection -> R.string.route_session_correction
AppRoute.Exercises -> R.string.nav_exercises; is AppRoute.ExerciseDetail -> R.string.route_exercise_detail AppRoute.Exercises -> R.string.nav_exercises; is AppRoute.ExerciseDetail -> R.string.route_exercise_detail
is AppRoute.ExerciseEdit -> R.string.route_exercise_edit; is AppRoute.ExerciseCreate -> R.string.route_exercise_create is AppRoute.ExerciseEdit -> R.string.route_exercise_edit; is AppRoute.ExerciseCreate -> R.string.route_exercise_create

View file

@ -24,6 +24,7 @@ sealed interface AppRoute {
data object AiSessionDrafts : AppRoute { override val section = AppSection.SESSIONS } data object AiSessionDrafts : AppRoute { override val section = AppSection.SESSIONS }
data object CompletedSessions : AppRoute { override val section = AppSection.SESSIONS } data object CompletedSessions : AppRoute { override val section = AppSection.SESSIONS }
data object Programs : AppRoute { override val section = AppSection.SESSIONS } data object Programs : AppRoute { override val section = AppSection.SESSIONS }
data object CardioCalibration : AppRoute { override val section = AppSection.SESSIONS }
data class ProgramDetail(val programId: String) : AppRoute { override val section = AppSection.SESSIONS } data class ProgramDetail(val programId: String) : AppRoute { override val section = AppSection.SESSIONS }
data class SessionDetail(val sessionId: String) : AppRoute { override val section = AppSection.SESSIONS } data class SessionDetail(val sessionId: String) : AppRoute { override val section = AppSection.SESSIONS }
data class SessionCorrection(val sessionId: String) : AppRoute { override val section = AppSection.SESSIONS } data class SessionCorrection(val sessionId: String) : AppRoute { override val section = AppSection.SESSIONS }

View file

@ -0,0 +1,172 @@
package com.labfytools.trainlog.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import com.labfytools.trainlog.R
import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.model.CardioCalibrationPhase
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
@Composable
fun CardioCalibrationScreen(
repository: TrainlogRepository,
onChanged: () -> Unit,
) {
val strings = localizedContext()
val colors = LocalTrainlogColors.current
var revision by remember { mutableIntStateOf(0) }
var message by remember { mutableStateOf<String?>(null) }
val active = remember(revision) { repository.activeCardioCalibration() }
val latest = remember(revision) {
repository.listCompletedCardioCalibrations(1).firstOrNull()
}
fun apply(result: TrainlogRepository.CardioCalibrationResult) {
message =
when (result) {
is TrainlogRepository.CardioCalibrationResult.Applied -> {
revision++
onChanged()
null
}
is TrainlogRepository.CardioCalibrationResult.Invalid -> result.message
TrainlogRepository.CardioCalibrationResult.Conflict ->
strings.getString(R.string.calibration_conflict)
is TrainlogRepository.CardioCalibrationResult.DatabaseError ->
result.message
}
}
fun phaseLabel(phase: CardioCalibrationPhase): String =
strings.getString(
when (phase) {
CardioCalibrationPhase.WARMUP -> R.string.calibration_phase_warmup
CardioCalibrationPhase.PROGRESSIVE -> R.string.calibration_phase_progressive
CardioCalibrationPhase.HIGH_EFFORT -> R.string.calibration_phase_high_effort
CardioCalibrationPhase.RECOVERY -> R.string.calibration_phase_recovery
CardioCalibrationPhase.COMPLETED -> R.string.calibration_phase_completed
CardioCalibrationPhase.ABORTED -> R.string.calibration_phase_aborted
},
)
TrainlogScreen(
strings.getString(R.string.route_cardio_calibration),
scrollKey = "cardio-calibration",
) {
TrainlogInfo(strings.getString(R.string.calibration_explanation), colors.muted)
if (active == null) {
TrainlogPrimaryAction(
strings.getString(R.string.calibration_start),
strings.getString(R.string.calibration_start_description),
) {
apply(repository.startCardioCalibration())
}
latest?.let { profile ->
TrainlogFrame(strings.getString(R.string.calibration_latest)) {
TrainlogInfo(
strings.getString(
R.string.calibration_peak_value,
profile.observedPeakBpm ?: 0,
),
colors.accent,
)
profile.recovery.forEach { point ->
TrainlogInfo(
strings.getString(
R.string.calibration_recovery_value,
point.targetOffsetSeconds / 60,
point.bpm,
),
colors.muted,
)
}
}
}
} else {
TrainlogFrame(strings.getString(R.string.calibration_current)) {
TrainlogInfo(
strings.getString(
R.string.calibration_phase_value,
phaseLabel(active.phase),
),
colors.accent,
)
active.observedPeakBpm?.let { bpm ->
TrainlogInfo(
strings.getString(R.string.calibration_peak_value, bpm),
colors.warning,
)
}
active.recovery.forEach { point ->
TrainlogInfo(
strings.getString(
R.string.calibration_recovery_value,
point.targetOffsetSeconds / 60,
point.bpm,
),
colors.muted,
)
}
}
when (active.phase) {
CardioCalibrationPhase.WARMUP -> {
TrainlogPrimaryAction(
strings.getString(R.string.calibration_to_progressive),
strings.getString(R.string.calibration_progressive_description),
) {
apply(repository.advanceCardioCalibrationPhase())
}
}
CardioCalibrationPhase.PROGRESSIVE -> {
TrainlogPrimaryAction(
strings.getString(R.string.calibration_to_high_effort),
strings.getString(R.string.calibration_high_effort_description),
) {
apply(repository.advanceCardioCalibrationPhase())
}
}
CardioCalibrationPhase.HIGH_EFFORT -> {
TrainlogPrimaryAction(
strings.getString(R.string.calibration_end_effort),
strings.getString(R.string.calibration_end_effort_description),
) {
apply(repository.markCardioCalibrationEffortEnd())
}
}
CardioCalibrationPhase.RECOVERY -> {
TrainlogPrimaryAction(
strings.getString(R.string.calibration_finish),
strings.getString(R.string.calibration_finish_description),
) {
apply(repository.finishCardioCalibration())
}
}
CardioCalibrationPhase.COMPLETED,
CardioCalibrationPhase.ABORTED -> Unit
}
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
TrainlogButton(
strings.getString(R.string.calibration_abort),
{ apply(repository.abortCardioCalibration()) },
style = TrainlogButtonStyle.DESTRUCTIVE,
)
}
}
message?.let { TrainlogInfo(it, colors.error) }
}
}

View file

@ -58,6 +58,7 @@ fun SessionsHub(
onDrafts: () -> Unit, onDrafts: () -> Unit,
onHistory: () -> Unit, onHistory: () -> Unit,
onPrograms: () -> Unit = {}, onPrograms: () -> Unit = {},
onCardioCalibration: () -> Unit = {},
) { ) {
val colors = LocalTrainlogColors.current val colors = LocalTrainlogColors.current
val strings = localizedContext() val strings = localizedContext()
@ -77,6 +78,13 @@ fun SessionsHub(
Modifier.weight(1f).fillMaxHeight().testTag("sessions-drafts-action")) Modifier.weight(1f).fillMaxHeight().testTag("sessions-drafts-action"))
} }
} }
TrainlogButton(
strings.getString(R.string.route_cardio_calibration),
onCardioCalibration,
Modifier.fillMaxWidth().testTag("sessions-cardio-calibration-action"),
style = TrainlogButtonStyle.SUCCESS,
)
Spacer(Modifier.height(10.dp))
TrainlogButton(strings.getString(R.string.route_completed_sessions), onHistory, TrainlogButton(strings.getString(R.string.route_completed_sessions), onHistory,
Modifier.fillMaxWidth().testTag("sessions-history-action")) Modifier.fillMaxWidth().testTag("sessions-history-action"))
Spacer(Modifier.height(16.dp).testTag("sessions-programs-spacing")) Spacer(Modifier.height(16.dp).testTag("sessions-programs-spacing"))

View file

@ -103,8 +103,16 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
}, },
{ open(AppRoute.BodyMeasurements) }, { open(AppRoute.Sync) }, { open(AppRoute.BodyMeasurements) }, { open(AppRoute.Sync) },
) )
AppRoute.Sessions -> SessionsHub(activeDraft, pendingAiDraftCount, { open(AppRoute.SessionEditor) }, ::openManualSession, AppRoute.Sessions -> SessionsHub(
{ open(AppRoute.AiSessionDrafts) }, { open(AppRoute.CompletedSessions) }, { open(AppRoute.Programs) }) activeDraft,
pendingAiDraftCount,
{ open(AppRoute.SessionEditor) },
::openManualSession,
{ open(AppRoute.AiSessionDrafts) },
{ open(AppRoute.CompletedSessions) },
{ open(AppRoute.Programs) },
{ open(AppRoute.CardioCalibration) },
)
AppRoute.SessionEditor -> SessionScreen( AppRoute.SessionEditor -> SessionScreen(
repository, repository,
catalogRevision, catalogRevision,
@ -137,6 +145,10 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
) )
AppRoute.CompletedSessions -> HistoryScreen(repository, { back() }) { open(AppRoute.SessionDetail(it)) } AppRoute.CompletedSessions -> HistoryScreen(repository, { back() }) { open(AppRoute.SessionDetail(it)) }
AppRoute.Programs -> ProgramsScreen(repository) { open(AppRoute.ProgramDetail(it)) } AppRoute.Programs -> ProgramsScreen(repository) { open(AppRoute.ProgramDetail(it)) }
AppRoute.CardioCalibration -> CardioCalibrationScreen(repository) {
draftRevision++
exportSnapshot()
}
is AppRoute.ProgramDetail -> ProgramDetailScreen(repository, route.programId) { is AppRoute.ProgramDetail -> ProgramDetailScreen(repository, route.programId) {
draftRevision++ draftRevision++
open(AppRoute.SessionEditor) open(AppRoute.SessionEditor)

View file

@ -560,4 +560,29 @@
<string name="sleep_quick_conflict">The night changed; reload before trying again.</string> <string name="sleep_quick_conflict">The night changed; reload before trying again.</string>
<string name="sleep_quick_error">Unable to record the action.</string> <string name="sleep_quick_error">Unable to record the action.</string>
<string name="session_cardio">Cardio</string> <string name="session_cardio">Cardio</string>
<string name="route_cardio_calibration">Cardio calibration</string>
<string name="calibration_explanation">This session measures your current heart-rate response. Effort increases progressively and you can stop immediately at any time. The result is stored as an observed heart-rate peak, not as a medical maximum heart rate.</string>
<string name="calibration_start">Start calibration</string>
<string name="calibration_start_description">Create a dedicated cardio session, start heart-rate capture and begin with warm-up.</string>
<string name="calibration_latest">Latest calibration</string>
<string name="calibration_current">Calibration in progress</string>
<string name="calibration_phase_value">Phase: %1$s</string>
<string name="calibration_phase_warmup">Warm-up</string>
<string name="calibration_phase_progressive">Progressive effort</string>
<string name="calibration_phase_high_effort">High effort</string>
<string name="calibration_phase_recovery">Recovery</string>
<string name="calibration_phase_completed">Completed</string>
<string name="calibration_phase_aborted">Stopped</string>
<string name="calibration_peak_value">Observed peak: %1$d BPM</string>
<string name="calibration_recovery_value">Recovery +%1$d min: %2$d BPM</string>
<string name="calibration_to_progressive">Start progressive effort</string>
<string name="calibration_progressive_description">Increase effort progressively without trying to beat a previous value.</string>
<string name="calibration_to_high_effort">Move to high effort</string>
<string name="calibration_high_effort_description">Continue only while you choose to tolerate the effort. The next button ends the effort phase immediately.</string>
<string name="calibration_end_effort">Stop effort</string>
<string name="calibration_end_effort_description">Mark the end of effort and begin measured recovery.</string>
<string name="calibration_finish">Finish calibration</string>
<string name="calibration_finish_description">Close recovery and save the calibration profile.</string>
<string name="calibration_abort">Stop calibration</string>
<string name="calibration_conflict">Another session or calibration is already active.</string>
</resources> </resources>

View file

@ -570,4 +570,29 @@
<string name="sleep_quick_conflict">La nuit a changé ; rechargez avant de recommencer.</string> <string name="sleep_quick_conflict">La nuit a changé ; rechargez avant de recommencer.</string>
<string name="sleep_quick_error">Enregistrement impossible.</string> <string name="sleep_quick_error">Enregistrement impossible.</string>
<string name="session_cardio">Cardio</string> <string name="session_cardio">Cardio</string>
<string name="route_cardio_calibration">Calibration cardio</string>
<string name="calibration_explanation">Cette séance mesure votre réponse cardiaque actuelle. L’effort augmente progressivement et vous pouvez arrêter immédiatement à tout moment. Le résultat est enregistré comme un pic de fréquence cardiaque observé, pas comme une FC max médicale.</string>
<string name="calibration_start">Démarrer la calibration</string>
<string name="calibration_start_description">Créer une séance cardio dédiée, démarrer la capture cardiaque et commencer par l’échauffement.</string>
<string name="calibration_latest">Dernière calibration</string>
<string name="calibration_current">Calibration en cours</string>
<string name="calibration_phase_value">Phase : %1$s</string>
<string name="calibration_phase_warmup">Échauffement</string>
<string name="calibration_phase_progressive">Montée progressive</string>
<string name="calibration_phase_high_effort">Effort élevé</string>
<string name="calibration_phase_recovery">Récupération</string>
<string name="calibration_phase_completed">Terminée</string>
<string name="calibration_phase_aborted">Arrêtée</string>
<string name="calibration_peak_value">Pic observé : %1$d BPM</string>
<string name="calibration_recovery_value">Récupération +%1$d min : %2$d BPM</string>
<string name="calibration_to_progressive">Commencer la montée progressive</string>
<string name="calibration_progressive_description">Augmenter progressivement l’effort sans chercher à battre une ancienne valeur.</string>
<string name="calibration_to_high_effort">Passer à l’effort élevé</string>
<string name="calibration_high_effort_description">Continuer seulement tant que vous choisissez de tolérer l’effort. Le bouton suivant arrête immédiatement la phase d’effort.</string>
<string name="calibration_end_effort">Arrêter l’effort</string>
<string name="calibration_end_effort_description">Marquer la fin de l’effort et commencer la récupération mesurée.</string>
<string name="calibration_finish">Terminer la calibration</string>
<string name="calibration_finish_description">Fermer la récupération et enregistrer le profil de calibration.</string>
<string name="calibration_abort">Arrêter la calibration</string>
<string name="calibration_conflict">Une autre séance ou calibration est déjà en cours.</string>
</resources> </resources>

View file

@ -66,7 +66,7 @@ class AndroidV10PlanningMigrationTest {
TrainlogRepository(context, name).useForTest { it.listSessions() } TrainlogRepository(context, name).useForTest { it.listSessions() }
SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db -> SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
assertEquals(29, scalar(db, "PRAGMA user_version")) assertEquals(30, scalar(db, "PRAGMA user_version"))
for (table in listOf("session_exercises", "draft_session_exercises")) { 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(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")) 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() migrated.close()
} }
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db -> SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
assertEquals(29, scalar(db, "PRAGMA user_version")) assertEquals(30, scalar(db, "PRAGMA user_version"))
/* v13 intentionally adds the frozen machine identities and /* v13 intentionally adds the frozen machine identities and
* exercise-owned metadata; all non-exercise facts stay exact. */ * 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") } 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() migrated.close()
} }
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db -> SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
assertEquals(29, scalar(db, "PRAGMA user_version")) assertEquals(30, scalar(db, "PRAGMA user_version"))
assertEquals(before, listOf( assertEquals(before, listOf(
scalar(db, "SELECT COUNT(*) FROM sessions"), scalar(db, "SELECT COUNT(*) FROM sessions"),
scalar(db, "SELECT COUNT(*) FROM performed_sets"), scalar(db, "SELECT COUNT(*) FROM performed_sets"),

View file

@ -69,7 +69,7 @@ class AndroidV25ProgramExecutionMigrationTest {
reopened.close() reopened.close()
} }
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db -> SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
assertEquals(29, scalar(db, "PRAGMA user_version")) assertEquals(30, scalar(db, "PRAGMA user_version"))
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM sessions WHERE source_program_id IS NOT NULL")) assertEquals(0, scalar(db, "SELECT COUNT(*) FROM sessions WHERE source_program_id IS NOT NULL"))
assertEquals("ok", text(db, "PRAGMA integrity_check")) assertEquals("ok", text(db, "PRAGMA integrity_check"))
db.rawQuery("PRAGMA foreign_key_check", null).use { cursor -> db.rawQuery("PRAGMA foreign_key_check", null).use { cursor ->

View file

@ -35,7 +35,7 @@ class AndroidV26SleepMigrationTest {
context.getDatabasePath(name).path, context.getDatabasePath(name).path,
null, null,
SQLiteDatabase.OPEN_READONLY, SQLiteDatabase.OPEN_READONLY,
).use { assertEquals(29, it.version) } ).use { assertEquals(30, it.version) }
} finally { } finally {
repository.close() repository.close()
context.deleteDatabase(name) context.deleteDatabase(name)

View file

@ -45,7 +45,7 @@ class AndroidV27HeartRateMigrationTest {
context.getDatabasePath(name).path, context.getDatabasePath(name).path,
null, null,
SQLiteDatabase.OPEN_READONLY, SQLiteDatabase.OPEN_READONLY,
).use { assertEquals(29, it.version) } ).use { assertEquals(30, it.version) }
} finally { } finally {
repository.close() repository.close()
context.deleteDatabase(name) context.deleteDatabase(name)

View file

@ -78,7 +78,7 @@ class AndroidV28SessionTimelineMigrationTest {
null, null,
SQLiteDatabase.OPEN_READONLY, SQLiteDatabase.OPEN_READONLY,
).use { db -> ).use { db ->
assertEquals(29, db.version) assertEquals(30, db.version)
db.rawQuery( db.rawQuery(
"SELECT session_id,started_at,ended_at FROM session_timeline_sessions", "SELECT session_id,started_at,ended_at FROM session_timeline_sessions",
null, null,

View file

@ -82,7 +82,7 @@ class AndroidV29CardioSessionMigrationTest {
null, null,
SQLiteDatabase.OPEN_READONLY, SQLiteDatabase.OPEN_READONLY,
).use { db -> ).use { db ->
assertEquals(29, db.version) assertEquals(30, db.version)
db.rawQuery( db.rawQuery(
"SELECT session_type,session_kind FROM sessions", "SELECT session_type,session_kind FROM sessions",
null, null,

View file

@ -0,0 +1,69 @@
package com.labfytools.trainlog.data
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import androidx.test.core.app.ApplicationProvider
import java.util.UUID
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class AndroidV30CardioCalibrationMigrationTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun v29AddsEmptyCalibrationDomainAndReservedExercise() {
val name = "calibration-v29-" + UUID.randomUUID() + ".db"
var repository = TrainlogRepository(context, name)
try {
repository.listExercises()
repository.close()
SQLiteDatabase.openDatabase(
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READWRITE,
).use { db ->
db.execSQL("DROP TABLE cardio_calibration_generation")
db.execSQL("DROP TABLE cardio_calibration_recovery")
db.execSQL("DROP TABLE cardio_calibrations")
db.version = 29
}
repository = TrainlogRepository(context, name)
assertTrue(repository.listExercises().none { it.name == "Calibration cardio" })
SQLiteDatabase.openDatabase(
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READONLY,
).use { db ->
assertEquals(30, db.version)
db.rawQuery(
"SELECT COUNT(*) FROM exercises WHERE " +
"exercise_id='ex_ca1b4a7e-1c2d-4f00-8a11-000000000001' " +
"AND name='Calibration cardio'",
null,
).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(1, cursor.getInt(0))
}
db.rawQuery(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN(" +
"'cardio_calibrations','cardio_calibration_recovery'," +
"'cardio_calibration_generation')",
null,
).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(3, cursor.getInt(0))
}
}
} finally {
repository.close()
context.deleteDatabase(name)
}
}
}

View file

@ -328,7 +328,7 @@ class BodyZonesTest {
SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null, SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null,
SQLiteDatabase.OPEN_READONLY).use { db -> SQLiteDatabase.OPEN_READONLY).use { db ->
db.rawQuery("PRAGMA user_version", null).use { cursor -> db.rawQuery("PRAGMA user_version", null).use { cursor ->
assertTrue(cursor.moveToFirst()); assertEquals(29, cursor.getInt(0)) assertTrue(cursor.moveToFirst()); assertEquals(30, cursor.getInt(0))
} }
db.rawQuery("SELECT id FROM exercises", null).use { cursor -> db.rawQuery("SELECT id FROM exercises", null).use { cursor ->
assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0)) assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0))

View file

@ -43,7 +43,7 @@ class BridgeBackupMigrationTest {
null, null,
android.database.sqlite.SQLiteDatabase.OPEN_READONLY, android.database.sqlite.SQLiteDatabase.OPEN_READONLY,
) )
.use { assertEquals(29, it.version) } .use { assertEquals(30, it.version) }
} finally { } finally {
repository.close() repository.close()
context.deleteDatabase(name) context.deleteDatabase(name)

View file

@ -0,0 +1,187 @@
package com.labfytools.trainlog.data
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.CardioCalibrationPhase
import com.labfytools.trainlog.model.SessionType
import java.util.UUID
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 CardioCalibrationRepositoryTest {
private val context: Context = ApplicationProvider.getApplicationContext()
private fun measurement(bpm: Int) =
ParsedHeartRateMeasurement(
bpm = bpm,
sensorContactDetected = null,
energyExpended = null,
rrIntervals1024 = listOf(1024),
)
@Test
fun calibrationTracksObservedPeakRecoveryAndFinalizesRealCardioSession() {
val name = "calibration-" + UUID.randomUUID() + ".db"
val repository = TrainlogRepository(context, name)
try {
val started =
repository.startCardioCalibration("2026-09-23T10:00:00+02:00")
as TrainlogRepository.CardioCalibrationResult.Applied
assertEquals(CardioCalibrationPhase.WARMUP, started.profile.phase)
repository.recordLiveHeartRateForActiveSession(
"CYCPLUS H2",
"2026-09-23T10:00:10+02:00",
measurement(80),
)
assertEquals(
CardioCalibrationPhase.PROGRESSIVE,
(repository.advanceCardioCalibrationPhase("2026-09-23T10:01:00+02:00")
as TrainlogRepository.CardioCalibrationResult.Applied).profile.phase,
)
repository.recordLiveHeartRateForActiveSession(
"CYCPLUS H2",
"2026-09-23T10:01:20+02:00",
measurement(112),
)
assertEquals(
CardioCalibrationPhase.HIGH_EFFORT,
(repository.advanceCardioCalibrationPhase("2026-09-23T10:02:00+02:00")
as TrainlogRepository.CardioCalibrationResult.Applied).profile.phase,
)
repository.recordLiveHeartRateForActiveSession(
"CYCPLUS H2",
"2026-09-23T10:02:30+02:00",
measurement(150),
)
repository.recordLiveHeartRateForActiveSession(
"CYCPLUS H2",
"2026-09-23T10:03:00+02:00",
measurement(160),
)
val recovery =
repository.markCardioCalibrationEffortEnd("2026-09-23T10:03:01+02:00")
as TrainlogRepository.CardioCalibrationResult.Applied
assertEquals(CardioCalibrationPhase.RECOVERY, recovery.profile.phase)
assertEquals(160, recovery.profile.observedPeakBpm)
assertTrue(recovery.profile.heartRateCaptureId != null)
repository.recordLiveHeartRateForActiveSession(
"CYCPLUS H2",
"2026-09-23T10:04:01+02:00",
measurement(140),
)
repository.recordLiveHeartRateForActiveSession(
"CYCPLUS H2",
"2026-09-23T10:05:02+02:00",
measurement(120),
)
repository.recordLiveHeartRateForActiveSession(
"CYCPLUS H2",
"2026-09-23T10:06:01+02:00",
measurement(100),
)
val completed =
repository.finishCardioCalibration("2026-09-23T10:06:10+02:00")
as TrainlogRepository.CardioCalibrationResult.Applied
assertEquals(CardioCalibrationPhase.COMPLETED, completed.profile.phase)
assertEquals(
listOf(60 to 140, 120 to 120, 180 to 100),
completed.profile.recovery.map { it.targetOffsetSeconds to it.bpm },
)
assertNull(repository.activeCardioCalibration())
assertNull(repository.activeHeartRateCapture())
val session = repository.listSessions().single()
assertEquals(SessionType.CARDIO, session.sessionType)
val detail = checkNotNull(repository.getSessionDetail(session.sessionId))
assertEquals(370, detail.exercises.single().continuousDurationSeconds)
assertEquals(1, repository.listCompletedCardioCalibrations().size)
} finally {
repository.close()
context.deleteDatabase(name)
}
}
@Test
fun recoveryDoesNotInventLateReferencePointsAndCannotFinishTooEarly() {
val name = "calibration-recovery-" + UUID.randomUUID() + ".db"
val repository = TrainlogRepository(context, name)
try {
repository.startCardioCalibration("2026-09-23T12:00:00+02:00")
repository.recordLiveHeartRateForActiveSession(
"CYCPLUS H2",
"2026-09-23T12:00:10+02:00",
measurement(80),
)
repository.advanceCardioCalibrationPhase("2026-09-23T12:01:00+02:00")
repository.advanceCardioCalibrationPhase("2026-09-23T12:02:00+02:00")
repository.recordLiveHeartRateForActiveSession(
"CYCPLUS H2",
"2026-09-23T12:02:30+02:00",
measurement(155),
)
repository.markCardioCalibrationEffortEnd("2026-09-23T12:03:00+02:00")
repository.recordLiveHeartRateForActiveSession(
"CYCPLUS H2",
"2026-09-23T12:05:05+02:00",
measurement(118),
)
val duringRecovery = checkNotNull(repository.activeCardioCalibration())
assertEquals(
listOf(120 to 118),
duringRecovery.recovery.map { it.targetOffsetSeconds to it.bpm },
)
val tooEarly =
repository.finishCardioCalibration("2026-09-23T12:05:59+02:00")
assertTrue(tooEarly is TrainlogRepository.CardioCalibrationResult.Invalid)
assertTrue(repository.activeCardioCalibration() != null)
repository.recordLiveHeartRateForActiveSession(
"CYCPLUS H2",
"2026-09-23T12:06:03+02:00",
measurement(105),
)
val completed =
repository.finishCardioCalibration("2026-09-23T12:06:05+02:00")
as TrainlogRepository.CardioCalibrationResult.Applied
assertEquals(
listOf(120 to 118, 180 to 105),
completed.profile.recovery.map { it.targetOffsetSeconds to it.bpm },
)
} finally {
repository.close()
context.deleteDatabase(name)
}
}
@Test
fun abortBeforeEffortEndNeverCreatesCompletedReference() {
val name = "calibration-abort-" + UUID.randomUUID() + ".db"
val repository = TrainlogRepository(context, name)
try {
repository.startCardioCalibration("2026-09-23T11:00:00+02:00")
val aborted =
repository.abortCardioCalibration("2026-09-23T11:01:00+02:00")
as TrainlogRepository.CardioCalibrationResult.Applied
assertEquals(CardioCalibrationPhase.ABORTED, aborted.profile.phase)
assertNull(aborted.profile.effortEndAt)
assertTrue(repository.loadActiveSessionDraft() is ActiveDraftLoadResult.None)
assertTrue(repository.listCompletedCardioCalibrations().isEmpty())
} finally {
repository.close()
context.deleteDatabase(name)
}
}
}

View file

@ -360,7 +360,7 @@ class SessionPreparationExchangeTest {
repository = TrainlogRepository(context, name) repository = TrainlogRepository(context, name)
assertEquals(1, repository.listPreparedSessions().size) assertEquals(1, repository.listPreparedSessions().size)
repository.close() repository.close()
assertEquals("29", scalarText(name, "PRAGMA user_version")) assertEquals("30", scalarText(name, "PRAGMA user_version"))
assertEquals( assertEquals(
"0", "0",
scalarText(name, "SELECT COUNT(*) FROM session_preparation_withdrawals"), scalarText(name, "SELECT COUNT(*) FROM session_preparation_withdrawals"),

View file

@ -509,7 +509,7 @@ class SyncGenerationServiceTest {
.use { db -> .use { db ->
db.rawQuery("PRAGMA user_version", null).use { db.rawQuery("PRAGMA user_version", null).use {
assertTrue(it.moveToFirst()) assertTrue(it.moveToFirst())
assertEquals(29, it.getInt(0)) assertEquals(30, it.getInt(0))
} }
db.rawQuery("SELECT COUNT(*) FROM exercises", null).use { db.rawQuery("SELECT COUNT(*) FROM exercises", null).use {
assertTrue(it.moveToFirst()) assertTrue(it.moveToFirst())

View file

@ -2260,7 +2260,7 @@ class TrainlogRepositoryDraftTest {
).use { db -> ).use { db ->
db.rawQuery("PRAGMA user_version;", null).use { cursor -> db.rawQuery("PRAGMA user_version;", null).use { cursor ->
assertTrue(cursor.moveToFirst()) assertTrue(cursor.moveToFirst())
assertEquals(29, cursor.getInt(0)) assertEquals(30, cursor.getInt(0))
} }
db.rawQuery( db.rawQuery(
"SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " + "SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " +
@ -2387,7 +2387,7 @@ class TrainlogRepositoryDraftTest {
).use { db -> ).use { db ->
db.rawQuery("PRAGMA user_version;", null).use { cursor -> db.rawQuery("PRAGMA user_version;", null).use { cursor ->
assertTrue(cursor.moveToFirst()) assertTrue(cursor.moveToFirst())
assertEquals(29, cursor.getInt(0)) assertEquals(30, cursor.getInt(0))
} }
db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor -> db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor ->
assertTrue(cursor.moveToFirst()) assertTrue(cursor.moveToFirst())

View file

@ -112,8 +112,8 @@ parallel implementation of its rules.
| Boundary | Current state | | Boundary | Current state |
|---|---| |---|---|
| Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` | | Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` |
| Desktop SQLite | schema v32; canonical `session_kind` adds true cardio sessions while frozen legacy `session_type` remains training/max_test | | Desktop SQLite | schema v33; versioned cardio calibration profiles/recovery are additive on top of canonical cardio session kind |
| Android SQLite | schema v29; canonical `session_kind` adds true cardio sessions while legacy draft/history codecs remain training/max_test | | Android SQLite | schema v30; Android owns active/completed cardio calibration profiles in addition to cardio sessions and HR capture |
| Mobile snapshot | V3 active; V1/V2 readable legacy inputs; explicit V4 codec staged, not selected by transport | | Mobile snapshot | V3 active; V1/V2 readable legacy inputs; explicit V4 codec staged, not selected by transport |
| Desktop terminal backend | Notcurses only | | Desktop terminal backend | Notcurses only |
| Trainlog product version | `0.1.5` development, synchronized across Android and desktop; latest stable release: `v0.1.4` | | Trainlog product version | `0.1.5` development, synchronized across Android and desktop; latest stable release: `v0.1.4` |
@ -153,7 +153,8 @@ parallel implementation of its rules.
| 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 | | Training timeline | `TRAINLOG_CARDIO_TRAINING_TIMELINE_V1=PASS`: real session bounds reuse canonical lifecycle timestamps; exercise start/end are explicit user actions; live BPM samples snapshot the active stable `entry_id`; session finalization refuses an unclosed exercise; timeline sync is Android→desktop idempotent |
| Sleep Android one-tap | `TRAINLOG_SLEEP_ANDROID_ONE_TAP_V1=SOFTWARE_PASS / DEVICE_VISUAL_PENDING`: existing Sleep Diary V1 now has one-tap Couché / medication / Réveil / Levé, guarded 10 s undo, full correction route and sleep-entry-owned cardio capture; vc41 installed without data clear, but final visual device review was blocked by the phone lock screen | | Sleep Android one-tap | `TRAINLOG_SLEEP_ANDROID_ONE_TAP_V1=SOFTWARE_PASS / DEVICE_VISUAL_PENDING`: existing Sleep Diary V1 now has one-tap Couché / medication / Réveil / Levé, guarded 10 s undo, full correction route and sleep-entry-owned cardio capture; vc41 installed without data clear, but final visual device review was blocked by the phone lock screen |
| Cardio session type | `TRAINLOG_CARDIO_SESSION_TYPE_V1=PASS`: Android v29 + desktop v32 use canonical `session_kind=cardio`; frozen mobile/history and draft formats keep legacy training/max_test; dedicated `trainlog-cardio-sessions` V1 syncs completed cardio sessions Android→desktop idempotently | | Cardio session type | `TRAINLOG_CARDIO_SESSION_TYPE_V1=PASS`: Android v29 + desktop v32 use canonical `session_kind=cardio`; frozen mobile/history and draft formats keep legacy training/max_test; dedicated `trainlog-cardio-sessions` V1 syncs completed cardio sessions Android→desktop idempotently |
| Current operational cursor | `TRAINLOG_CARDIO_CALIBRATION_V1`: add the first versioned Calibration cardio exercise, observed peak BPM and recovery timeline without claiming a physiological maximum | | Cardio calibration | `TRAINLOG_CARDIO_CALIBRATION_V1=PASS`: reserved system Calibration cardio exercise, real measured observed peak, ≥3 min recovery with bounded +1/+2/+3 min factual samples, immutable profile and Android→desktop calibration companion |
| Current operational cursor | `TRAINLOG_CARDIO_BPM_GUIDANCE_V1`: define phase targets, hysteresis, persistence and fresh-signal-only ACCÉLÈRE / MAINTIENS / RALENTIS guidance |
| Complete synchronization gap contract | Frozen dependency contract; operational USB/Drive slices required by v0.1.2 are delivered | | 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` | | Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` |
| Synchronization characterization | `TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` | | Synchronization characterization | `TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` |

View file

@ -1,5 +1,13 @@
# Desktop database # Desktop database
## Schema v33: synchronized Cardio calibration V1
Schema v33 additively stores completed Android-owned Cardio calibration profiles. `cardio_calibrations` is keyed by stable `calibration_id` and retains protocol version, canonical cardio `session_id`, stable occurrence `entry_id`, exact calibration/effort/end timestamps, the synchronized heart-rate `capture_id`, observed peak BPM and import time.
`cardio_calibration_recovery` stores only factual recovery samples for the defined +60, +120 and +180 second references. The source curve remains Heart Rate V1; the calibration importer verifies that the observed peak and every recovery `(observed_at, bpm)` pair exist in that raw capture. It never derives, interpolates or rewrites HR samples.
Android owns the active calibration lifecycle. Desktop stores only completed profiles. A later calibration creates a new stable profile and never rewrites an older session or reference.
## Schema v32: canonical cardio session kind ## Schema v32: canonical cardio session kind
Schema v32 adds the product-owned `sessions.session_kind` column with the Schema v32 adds the product-owned `sessions.session_kind` column with the

View file

@ -69,7 +69,7 @@ sensor. Heart-rate ingestion must not reuse or overload the frozen Trainlog
Bluetooth synchronization transport contract. Bluetooth synchronization transport contract.
```text ```text
CURRENT_OPERATIONAL_CURSOR=TRAINLOG_CARDIO_CALIBRATION_V1 CURRENT_OPERATIONAL_CURSOR=TRAINLOG_CARDIO_BPM_GUIDANCE_V1
``` ```
The focused `TRAINLOG_SYNC_CAUSAL_DELETE_V1_CLOSEOUT` is complete before this The focused `TRAINLOG_SYNC_CAUSAL_DELETE_V1_CLOSEOUT` is complete before this

View file

@ -1127,7 +1127,15 @@ facts are applied.
Programs, manual preparations, AI execution drafts, mobile V3/V4 and Programs, manual preparations, AI execution drafts, mobile V3/V4 and
`TRAINLOG_FORMAT_V1` are not widened by this companion. `TRAINLOG_FORMAT_V1` are not widened by this companion.
## 22. Legacy active-protocol limitations ## 22. Cardio calibration companion V1
`trainlog-cardio-calibrations` V1 is an optional Android→desktop full-generation participant containing only completed calibration profiles. It references an already synchronized cardio session, its reserved stable occurrence, and an immutable stopped Heart Rate V1 capture.
Before import, desktop proves that the referenced session/occurrence and HR capture already exist and match. The declared observed peak must equal a BPM actually present in that capture, and each recovery point must match an exact raw `(observed_at, bpm)` sample. Exact replay is idempotent; stable identity reuse with different metadata or recovery facts is rejected.
Android generation membership is retained until a correlated consumed ACK. Acknowledged profiles remain local history and are not ordinarily republished. No calibration artifact claims a physiological maximum: the persisted value is the highest BPM actually observed in that calibration capture before recovery.
## 23. Legacy active-protocol limitations
The Web end-to-end tranche adds an explicit opt-in application orchestrator. 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 `POST /api/v1/sync` admits one correlated run and `GET /api/v1/sync/status` is

View file

@ -1798,3 +1798,13 @@ The 2026-09-23 closeout passes the complete native Meson inventory:
**106/106 passed, 0 failed**. The complete Android JVM inventory reports **106/106 passed, 0 failed**. The complete Android JVM inventory reports
**277 tests: 272 passed, five skipped, zero failures/errors**; `assembleDebug` **277 tests: 272 passed, five skipped, zero failures/errors**; `assembleDebug`
also succeeds. The five skips remain optional historical/external fixture gates. also succeeds. The five skips remain optional historical/external fixture gates.
## Cardio calibration V1 validation
`TRAINLOG_CARDIO_CALIBRATION_V1=PASS` covers the Android v29→v30 and desktop v32→v33 additive migrations, reserved system exercise ownership, real cardio session/timeline/HR capture integration, observed peak tracking, recovery, exact replay and generation synchronization.
The calibration system exercise keeps a stable `exercise_id` for occurrence foreign keys but is deliberately absent from the ordinary exercise catalogue, BODY ZONES, profile-state and PC-catalog companions. This preserves existing exercise-domain invariants while the dedicated cardio session/calibration companions own the system occurrence.
Recovery cannot complete before three minutes after effort end. The +60/+120/+180 second references accept only a real HR sample arriving from the target instant through ten seconds after it. A missing sensor interval remains missing rather than being populated by a later reconnect.
The desktop importer independently rejects a peak not supported by the raw HR capture and rejects a recovery point that is not an exact raw sample. The 2026-09-23 closeout passes the complete native inventory **108/108** and the complete Android JVM inventory **281 tests: 276 passed, five skipped, zero failures/errors**; `assembleDebug` also succeeds.

View file

@ -0,0 +1,175 @@
#!/usr/bin/env python3
import subprocess
import sys
import tempfile
import unittest
from copy import deepcopy
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "tools"))
import cardio_calibration_exchange as calibration
from trainlog_sqlite import connect_database
CAL = "cal_11111111-1111-4111-8111-111111111111"
SESSION = "se_22222222-2222-4222-8222-222222222222"
ENTRY = "sxe_33333333-3333-4333-8333-333333333333"
CAPTURE = "hrc_44444444-4444-4444-8444-444444444444"
EXERCISE = "ex_ca1b4a7e-1c2d-4f00-8a11-000000000001"
def document() -> dict:
return {
"format": "trainlog-cardio-calibrations",
"version": 1,
"generated_at": "2026-09-23T10:07:00+02:00",
"calibrations": [
{
"calibration_id": CAL,
"protocol_version": 1,
"session_id": SESSION,
"entry_id": ENTRY,
"started_at": "2026-09-23T10:00:00+02:00",
"effort_end_at": "2026-09-23T10:03:01+02:00",
"ended_at": "2026-09-23T10:06:10+02:00",
"heart_rate_capture_id": CAPTURE,
"observed_peak_bpm": 160,
"recovery": [
{
"target_offset_seconds": 60,
"observed_at": "2026-09-23T10:04:01+02:00",
"bpm": 140,
},
{
"target_offset_seconds": 120,
"observed_at": "2026-09-23T10:05:02+02:00",
"bpm": 120,
},
{
"target_offset_seconds": 180,
"observed_at": "2026-09-23T10:06:01+02:00",
"bpm": 100,
},
],
}
],
}
class CardioCalibrationExchangeTest(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.db_path = Path(self.temp.name) / "trainlog.sqlite"
fixture = ROOT / "build/tui/sync-generation-fixture"
subprocess.run([str(fixture), str(self.db_path)], check=True, capture_output=True)
self.db = connect_database(self.db_path)
self.db.execute(
"INSERT INTO exercises(exercise_id,name,normalized_name,recording_mode,tracking_mode,data_fields) "
"VALUES(?,?,?,?,?,?)",
(EXERCISE, "Calibration cardio", "calibration cardio", "continuous", "duration", 0),
)
exercise_row = self.db.execute(
"SELECT id FROM exercises WHERE exercise_id=?", (EXERCISE,)
).fetchone()[0]
self.db.execute(
"INSERT INTO sessions(session_id,started_at,ended_at,session_type,session_kind) "
"VALUES(?,?,?,?,?)",
(
SESSION,
"2026-09-23T10:00:00+02:00",
"2026-09-23T10:06:10+02:00",
"training",
"cardio",
),
)
session_row = self.db.execute(
"SELECT id FROM sessions WHERE session_id=?", (SESSION,)
).fetchone()[0]
self.db.execute(
"INSERT INTO session_exercises("
"entry_id,session_row_id,exercise_row_id,recording_mode,tracking_mode,"
"data_fields,position,load_mode,rest_seconds) VALUES(?,?,?,?,?,?,?,?,?)",
(ENTRY, session_row, exercise_row, "continuous", "duration", 0, 0, "none", 0),
)
self.db.execute(
"INSERT INTO heart_rate_captures("
"capture_id,context_kind,context_id,started_at,ended_at,sensor_name,imported_at"
") VALUES(?,?,?,?,?,?,?)",
(
CAPTURE,
"cardio",
SESSION,
"2026-09-23T10:00:00+02:00",
"2026-09-23T10:06:10+02:00",
"CYCPLUS H2",
"2026-09-23T10:07:00+02:00",
),
)
samples = [
("2026-09-23T10:00:10+02:00", 80),
("2026-09-23T10:01:20+02:00", 112),
("2026-09-23T10:02:30+02:00", 150),
("2026-09-23T10:03:00+02:00", 160),
("2026-09-23T10:04:01+02:00", 140),
("2026-09-23T10:05:02+02:00", 120),
("2026-09-23T10:06:01+02:00", 100),
]
self.db.executemany(
"INSERT INTO heart_rate_samples("
"capture_id,sequence,observed_at,bpm,exercise_entry_id,"
"sensor_contact_detected,energy_expended) VALUES(?,?,?,?,?,?,?)",
[
(CAPTURE, index, observed_at, bpm, ENTRY, None, None)
for index, (observed_at, bpm) in enumerate(samples)
],
)
self.db.commit()
def tearDown(self):
self.db.close()
self.temp.cleanup()
def test_import_and_exact_replay_are_factual_and_idempotent(self):
payload = calibration.validate(document())
self.assertEqual((1, 0), calibration.apply(self.db, payload))
self.assertEqual((0, 1), calibration.apply(self.db, payload))
self.assertEqual(
(1, SESSION, CAPTURE, 160),
tuple(
self.db.execute(
"SELECT protocol_version,session_id,heart_rate_capture_id,observed_peak_bpm "
"FROM cardio_calibrations WHERE calibration_id=?",
(CAL,),
).fetchone()
),
)
self.assertEqual(
[(60, 140), (120, 120), (180, 100)],
[
tuple(row)
for row in self.db.execute(
"SELECT target_offset_seconds,bpm FROM cardio_calibration_recovery "
"WHERE calibration_id=? ORDER BY target_offset_seconds",
(CAL,),
)
],
)
def test_rejects_peak_not_supported_by_raw_capture(self):
changed = deepcopy(document())
changed["calibrations"][0]["observed_peak_bpm"] = 159
with self.assertRaisesRegex(ValueError, "peak BPM"):
calibration.apply(self.db, calibration.validate(changed))
def test_rejects_recovery_point_not_present_in_raw_capture(self):
changed = deepcopy(document())
changed["calibrations"][0]["recovery"][0]["bpm"] = 139
with self.assertRaisesRegex(ValueError, "not a raw heart-rate sample"):
calibration.apply(self.db, calibration.validate(changed))
if __name__ == "__main__":
unittest.main()

View file

@ -66,13 +66,14 @@ class DeploymentToolsTest(unittest.TestCase):
self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(result.returncode, 0, result.stderr)
inventory = json.loads((output / "candidate-inventory.json").read_text()) inventory = json.loads((output / "candidate-inventory.json").read_text())
self.assertEqual(inventory["product_version"], "0.1.5") self.assertEqual(inventory["product_version"], "0.1.5")
self.assertEqual(inventory["desktop_schema"], 32) self.assertEqual(inventory["desktop_schema"], 33)
self.assertEqual(inventory["android_schema"], 29) self.assertEqual(inventory["android_schema"], 30)
self.assertIn("session-preparations-v2", inventory["protocols"]) self.assertIn("session-preparations-v2", inventory["protocols"])
self.assertIn("programs-v1", inventory["protocols"]) self.assertIn("programs-v1", inventory["protocols"])
self.assertIn("program-executions-v1", inventory["protocols"]) self.assertIn("program-executions-v1", inventory["protocols"])
self.assertIn("sleep-diary-v1", inventory["protocols"]) self.assertIn("sleep-diary-v1", inventory["protocols"])
self.assertIn("cardio-sessions-v1", inventory["protocols"]) self.assertIn("cardio-sessions-v1", inventory["protocols"])
self.assertIn("cardio-calibrations-v1", inventory["protocols"])
self.assertIn("heart-rate-v1", inventory["protocols"]) self.assertIn("heart-rate-v1", inventory["protocols"])
self.assertIn("session-timeline-v1", inventory["protocols"]) self.assertIn("session-timeline-v1", inventory["protocols"])
self.assertIn("bluetooth-files-v1", inventory["protocols"]) self.assertIn("bluetooth-files-v1", inventory["protocols"])

View file

@ -0,0 +1,282 @@
#!/usr/bin/env python3
"""Validate/import completed Android cardio calibration profiles V1."""
import argparse
import json
import re
import sqlite3
from datetime import datetime, timedelta
from pathlib import Path
from trainlog_sqlite import connect_database
FORMAT = "trainlog-cardio-calibrations"
VERSION = 1
MAX_BYTES = 4 * 1024 * 1024
MAX_CALIBRATIONS = 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}"
CALIBRATION_ID = re.compile(rf"^cal_{UUID4}$")
SESSION_ID = re.compile(rf"^se_{UUID4}$")
ENTRY_ID = re.compile(rf"^sxe_{UUID4}$")
CAPTURE_ID = re.compile(rf"^hrc_{UUID4}$")
CALIBRATION_EXERCISE_ID = "ex_ca1b4a7e-1c2d-4f00-8a11-000000000001"
RECOVERY_OFFSETS = (60, 120, 180)
class CardioCalibrationError(ValueError):
pass
def fail(message: str) -> None:
raise CardioCalibrationError(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 cardio calibration timestamp")
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError as error:
raise CardioCalibrationError("invalid cardio calibration timestamp") from error
if parsed.utcoffset() is None:
fail("cardio calibration timestamp lacks offset")
return value, parsed
def validate(root: object) -> dict:
if not isinstance(root, dict) or set(root) != {
"format", "version", "generated_at", "calibrations"
}:
fail("invalid cardio calibration envelope")
if root["format"] != FORMAT or type(root["version"]) is not int or root["version"] != VERSION:
fail("unsupported cardio calibration format")
timestamp(root["generated_at"])
calibrations = root["calibrations"]
if not isinstance(calibrations, list) or len(calibrations) > MAX_CALIBRATIONS:
fail("cardio calibration bound exceeded")
seen = set()
for item in calibrations:
keys = {
"calibration_id", "protocol_version", "session_id", "entry_id",
"started_at", "effort_end_at", "ended_at", "heart_rate_capture_id",
"observed_peak_bpm", "recovery",
}
if not isinstance(item, dict) or set(item) != keys:
fail("invalid cardio calibration object")
calibration_id = item["calibration_id"]
if (
not isinstance(calibration_id, str)
or not CALIBRATION_ID.fullmatch(calibration_id)
or calibration_id in seen
):
fail("invalid or duplicate cardio calibration identity")
seen.add(calibration_id)
if type(item["protocol_version"]) is not int or item["protocol_version"] != 1:
fail("unsupported cardio calibration protocol")
if not isinstance(item["session_id"], str) or not SESSION_ID.fullmatch(item["session_id"]):
fail("invalid cardio calibration session identity")
if not isinstance(item["entry_id"], str) or not ENTRY_ID.fullmatch(item["entry_id"]):
fail("invalid cardio calibration entry identity")
if (
not isinstance(item["heart_rate_capture_id"], str)
or not CAPTURE_ID.fullmatch(item["heart_rate_capture_id"])
):
fail("invalid cardio calibration capture identity")
_, started = timestamp(item["started_at"])
_, effort_end = timestamp(item["effort_end_at"])
_, ended = timestamp(item["ended_at"])
if not started <= effort_end <= ended:
fail("invalid cardio calibration interval")
if type(item["observed_peak_bpm"]) is not int or not 0 <= item["observed_peak_bpm"] <= 65535:
fail("invalid observed peak BPM")
recovery = item["recovery"]
if not isinstance(recovery, list) or len(recovery) > 3:
fail("invalid cardio calibration recovery")
offsets = []
for point in recovery:
if not isinstance(point, dict) or set(point) != {
"target_offset_seconds", "observed_at", "bpm"
}:
fail("invalid recovery point")
offset = point["target_offset_seconds"]
if type(offset) is not int or offset not in RECOVERY_OFFSETS or offset in offsets:
fail("invalid recovery offset")
offsets.append(offset)
_, observed = timestamp(point["observed_at"])
if observed < effort_end + timedelta(seconds=offset) or observed > ended:
fail("recovery point outside factual interval")
if type(point["bpm"]) is not int or not 0 <= point["bpm"] <= 65535:
fail("invalid recovery BPM")
if offsets != sorted(offsets):
fail("recovery points must be ordered")
return root
def load(path: Path) -> dict:
if path.stat().st_size > MAX_BYTES:
fail("cardio calibration artifact exceeds 4 MiB")
try:
return validate(
json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=unique_object)
)
except OSError as error:
raise CardioCalibrationError("cannot read cardio calibration artifact") from error
def _verify_source_facts(db: sqlite3.Connection, item: dict) -> None:
session = db.execute(
"SELECT id,started_at,ended_at,session_kind FROM sessions WHERE session_id=?",
(item["session_id"],),
).fetchone()
if session is None or session[3] != "cardio":
fail("calibration session is missing or not cardio")
if session[1] != item["started_at"] or session[2] != item["ended_at"]:
fail("calibration session bounds differ from canonical history")
occurrence = 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[0], item["entry_id"]),
).fetchone()
if occurrence is None or occurrence[0] != CALIBRATION_EXERCISE_ID:
fail("calibration occurrence does not match reserved exercise")
capture = db.execute(
"SELECT context_kind,context_id,started_at,ended_at FROM heart_rate_captures "
"WHERE capture_id=?",
(item["heart_rate_capture_id"],),
).fetchone()
if (
capture is None
or capture[0] != "cardio"
or capture[1] != item["session_id"]
or capture[2] != item["started_at"]
or capture[3] != item["ended_at"]
):
fail("calibration heart-rate capture does not match session")
_, effort_end = timestamp(item["effort_end_at"])
samples = db.execute(
"SELECT observed_at,bpm FROM heart_rate_samples WHERE capture_id=? ORDER BY sequence",
(item["heart_rate_capture_id"],),
).fetchall()
before_effort_end = [
bpm
for observed_at, bpm in samples
if timestamp(observed_at)[1] <= effort_end
]
if not before_effort_end or max(before_effort_end) != item["observed_peak_bpm"]:
fail("observed peak BPM does not match raw capture")
sample_pairs = {(observed_at, bpm) for observed_at, bpm in samples}
for point in item["recovery"]:
if (point["observed_at"], point["bpm"]) not in sample_pairs:
fail("recovery point is not a raw heart-rate sample")
def _same(db: sqlite3.Connection, item: dict) -> bool:
row = db.execute(
"SELECT protocol_version,session_id,entry_id,started_at,effort_end_at,ended_at,"
"heart_rate_capture_id,observed_peak_bpm FROM cardio_calibrations "
"WHERE calibration_id=?",
(item["calibration_id"],),
).fetchone()
if row is None:
return False
expected = (
item["protocol_version"],
item["session_id"],
item["entry_id"],
item["started_at"],
item["effort_end_at"],
item["ended_at"],
item["heart_rate_capture_id"],
item["observed_peak_bpm"],
)
if tuple(row) != expected:
fail("calibration identity reused with different metadata")
stored = [
tuple(row)
for row in db.execute(
"SELECT target_offset_seconds,observed_at,bpm FROM cardio_calibration_recovery "
"WHERE calibration_id=? ORDER BY target_offset_seconds",
(item["calibration_id"],),
)
]
incoming = [
(p["target_offset_seconds"], p["observed_at"], p["bpm"])
for p in item["recovery"]
]
if stored != incoming:
fail("calibration identity reused with different recovery")
return True
def apply(db: sqlite3.Connection, root: dict) -> tuple[int, int]:
applied = unchanged = 0
for item in root["calibrations"]:
_verify_source_facts(db, item)
if _same(db, item):
unchanged += 1
continue
db.execute(
"INSERT INTO cardio_calibrations("
"calibration_id,protocol_version,session_id,entry_id,started_at,effort_end_at,"
"ended_at,heart_rate_capture_id,observed_peak_bpm,imported_at"
") VALUES(?,?,?,?,?,?,?,?,?,?)",
(
item["calibration_id"],
item["protocol_version"],
item["session_id"],
item["entry_id"],
item["started_at"],
item["effort_end_at"],
item["ended_at"],
item["heart_rate_capture_id"],
item["observed_peak_bpm"],
root["generated_at"],
),
)
db.executemany(
"INSERT INTO cardio_calibration_recovery("
"calibration_id,target_offset_seconds,observed_at,bpm) VALUES(?,?,?,?)",
[
(
item["calibration_id"],
point["target_offset_seconds"],
point["observed_at"],
point["bpm"],
)
for point in item["recovery"]
],
)
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] != 33:
fail("desktop schema v33 required")
db.execute("BEGIN IMMEDIATE")
apply(db, root)
db.commit()
if __name__ == "__main__":
main()

View file

@ -38,8 +38,8 @@ def digest(value) -> str:
def require_schema(db: sqlite3.Connection) -> None: def require_schema(db: sqlite3.Connection) -> None:
version = db.execute("PRAGMA user_version").fetchone()[0] version = db.execute("PRAGMA user_version").fetchone()[0]
if version not in (20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32): if version not in (20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33):
raise CausalError(f"desktop schema v20-v32 required, found v{version}") raise CausalError(f"desktop schema v20-v33 required, found v{version}")
def target_snapshot(db: sqlite3.Connection, kind: str, target: str): def target_snapshot(db: sqlite3.Connection, kind: str, target: str):

View file

@ -182,8 +182,8 @@ def causal_payload(draft):
def require_schema(connection): def require_schema(connection):
version = connection.execute("PRAGMA user_version").fetchone()[0] version = connection.execute("PRAGMA user_version").fetchone()[0]
if version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32): if version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33):
raise LifecycleError(f"desktop schema v19-v32 required, found v{version}") raise LifecycleError(f"desktop schema v19-v33 required, found v{version}")
def import_document(connection, document, own_transaction=True): def import_document(connection, document, own_transaction=True):

View file

@ -12,7 +12,7 @@ from pathlib import Path
from trainlog_sqlite import configure_connection from trainlog_sqlite import configure_connection
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32) SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33)
MEASUREMENT_FIELDS = ( MEASUREMENT_FIELDS = (
"body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm", "body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm",
"hips_cm", "left_arm_cm", "right_arm_cm", "left_forearm_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.""" """Build the raw export using only explicitly ordered SELECT statements."""
schema_version = connection.execute("PRAGMA user_version").fetchone()[0] schema_version = connection.execute("PRAGMA user_version").fetchone()[0]
if schema_version not in SUPPORTED_SCHEMA_VERSIONS: if schema_version not in SUPPORTED_SCHEMA_VERSIONS:
raise ValueError("schema desktop v15-v32 requis") raise ValueError("schema desktop v15-v33 requis")
root = { root = {
"format": "TRAINLOG_AI_EXPORT", "format": "TRAINLOG_AI_EXPORT",

View file

@ -20,9 +20,9 @@ ID_PREFIX = "aid_"
def ensure_publication_state(connection): def ensure_publication_state(connection):
"""Install the additive v18 cursor for direct syncd/tool invocations.""" """Install the additive v18 cursor for direct syncd/tool invocations."""
owned_transaction = not connection.in_transaction owned_transaction = not connection.in_transaction
supported_versions = (18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32) supported_versions = (18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v18-v32 requis") raise ValueError("schema desktop v18-v33 requis")
columns = {row[1] for row in connection.execute( columns = {row[1] for row in connection.execute(
"PRAGMA table_info(ai_session_drafts)")} "PRAGMA table_info(ai_session_drafts)")}
if "published_at" not in columns: if "published_at" not in columns:

View file

@ -28,9 +28,9 @@ def main():
args = parser.parse_args() args = parser.parse_args()
connection = connect_database(args.database) connection = connect_database(args.database)
try: try:
supported_versions = tuple(range(8, 33)) supported_versions = tuple(range(8, 34))
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions: if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v8 à v32 requis") raise ValueError("schema desktop v8 à v33 requis")
known_equipment = load_supplied_equipment_ids(args.catalog) known_equipment = load_supplied_equipment_ids(args.catalog)
known_equipment.update(row[0] for row in connection.execute( known_equipment.update(row[0] for row in connection.execute(
"SELECT equipment_id FROM custom_equipment")) "SELECT equipment_id FROM custom_equipment"))

View file

@ -24,9 +24,9 @@ def main(complete_causal_envelope=False):
connection = connect_database(args.database) connection = connect_database(args.database)
connection.row_factory = sqlite3.Row connection.row_factory = sqlite3.Row
try: try:
supported_versions = tuple(range(8, 33)) supported_versions = tuple(range(8, 34))
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v8 à v32 requis") raise ValueError("schema desktop v8 à v33 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(): 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") raise ValueError("causal equipment protection requires the staged artifact")
equipment = [dict(row) for row in connection.execute( equipment = [dict(row) for row in connection.execute(

View file

@ -17,8 +17,8 @@ def main():
con = connect_database(args.database) con = connect_database(args.database)
try: try:
if con.execute("PRAGMA user_version").fetchone()[0] not in ( if con.execute("PRAGMA user_version").fetchone()[0] not in (
12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32): 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33):
raise ValueError("schema desktop v12-v32 requis") raise ValueError("schema desktop v12-v33 requis")
rows = con.execute( rows = con.execute(
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases " "SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "
"ORDER BY source_exercise_id COLLATE BINARY" "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 = connect_database(args.database)
connection.row_factory = sqlite3.Row connection.row_factory = sqlite3.Row
try: try:
supported_versions = tuple(range(11, 33)) supported_versions = tuple(range(11, 34))
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v11-v32 requis") raise ValueError("schema desktop v11-v33 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(): 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") raise ValueError("causal BODY ZONES protection requires the staged artifact")
exercises = [] exercises = []

View file

@ -25,7 +25,7 @@ def main():
con.row_factory = sqlite3.Row con.row_factory = sqlite3.Row
try: try:
version = con.execute("PRAGMA user_version").fetchone()[0] version = con.execute("PRAGMA user_version").fetchone()[0]
if version not in (17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32): if version not in (17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33):
raise SystemExit(f"EXERCISE_PROFILE_STATE_EXPORT=FAIL schema={version}") raise SystemExit(f"EXERCISE_PROFILE_STATE_EXPORT=FAIL schema={version}")
rows = con.execute(""" rows = con.execute("""
SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields, 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 # CONTRACT: v8 adds only desktop-local custom equipment. The PC
# catalogue artifact is unchanged, but it must read the current # catalogue artifact is unchanged, but it must read the current
# canonical desktop schema rather than accept a stale pre-v8 database. # canonical desktop schema rather than accept a stale pre-v8 database.
if version not in range(8, 33): if version not in range(8, 34):
raise SystemExit( raise SystemExit(
"PC_CATALOG_EXPORT=FAIL " "PC_CATALOG_EXPORT=FAIL "
f"schema={version}" f"schema={version}"

View file

@ -83,12 +83,12 @@ def main(complete_causal_envelope=False):
con.row_factory = sqlite3.Row con.row_factory = sqlite3.Row
try: try:
schema_version = con.execute("PRAGMA user_version").fetchone()[0] schema_version = con.execute("PRAGMA user_version").fetchone()[0]
if args.version == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32): if args.version == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33):
raise ValueError("mobile V4 exige le schéma desktop v19 ou v20") raise ValueError("mobile V4 exige le schéma desktop v19 ou v20")
supported_versions = range(11, 33) supported_versions = range(11, 34)
legacy_v2_schema = args.version == 2 and schema_version == 10 legacy_v2_schema = args.version == 2 and schema_version == 10
if schema_version not in supported_versions and not legacy_v2_schema: if schema_version not in supported_versions and not legacy_v2_schema:
raise ValueError("schema desktop v11-v32 requis (v10 accepté pour export V2 explicite)") raise ValueError("schema desktop v11-v33 requis (v10 accepté pour export V2 explicite)")
if not complete_causal_envelope and schema_version >= 20 and con.execute( if not complete_causal_envelope and schema_version >= 20 and con.execute(
"SELECT 1 FROM sync_causal_state WHERE deleted=1 LIMIT 1").fetchone(): "SELECT 1 FROM sync_causal_state WHERE deleted=1 LIMIT 1").fetchone():
raise ValueError("causal protection refuses a mobile snapshot that omits tombstones") 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: def export_programs(database: Path) -> dict:
with closing(connect_database(database)) as db: with closing(connect_database(database)) as db:
if db.execute("PRAGMA user_version").fetchone()[0] not in (26, 27, 28, 29, 30, 31, 32): if db.execute("PRAGMA user_version").fetchone()[0] not in (26, 27, 28, 29, 30, 31, 32, 33):
raise ValueError("desktop schema v26 through v32 required") raise ValueError("desktop schema v26 through v32 required")
programs = [] programs = []
for row in db.execute( for row in db.execute(

View file

@ -17,7 +17,7 @@ MAX_WITHDRAWALS = 128
def build_export(connection: sqlite3.Connection) -> dict: def build_export(connection: sqlite3.Connection) -> dict:
supported_versions = (24, 25, 26, 27, 28, 29, 30, 31, 32) supported_versions = (24, 25, 26, 27, 28, 29, 30, 31, 32, 33)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("desktop schema v24, v25, v26 through v32 required") raise ValueError("desktop schema v24, v25, v26 through v32 required")
deliveries = [] 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() 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) db=connect_database(args.database)
try: try:
supported_versions = range(15, 33) supported_versions = range(15, 34)
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v15-v32 requis") raise ValueError("schema desktop v15-v33 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") 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() 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() 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() args = parser.parse_args()
root = load(args.input) root = load(args.input)
with connect_database(args.database) as db: with connect_database(args.database) as db:
if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32): if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32, 33):
fail("desktop schema v30-v32 required") fail("desktop schema v30-v33 required")
db.execute("BEGIN IMMEDIATE") db.execute("BEGIN IMMEDIATE")
apply(db, root) apply(db, root)
db.commit() 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; INVARIANT: validation and every child insert share one IMMEDIATE transaction;
any failure leaves no draft, child, or import-identity row behind. any failure leaves no draft, child, or import-identity row behind.
""" """
supported_versions = range(18, 33) supported_versions = range(18, 34)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ImportFailure("schema desktop v18-v32 requis") raise ImportFailure("schema desktop v18-v33 requis")
draft, entries = validate(payload, connection) draft, entries = validate(payload, connection)
digest = canonical_digest(payload) digest = canonical_digest(payload)
imported_at = imported_at or dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") 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")) payload = json.loads(args.artifact.read_text(encoding="utf-8"))
connection = connect_database(args.database) connection = connect_database(args.database)
try: try:
supported_versions = range(8, 33) supported_versions = range(8, 34)
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions: if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
fail("schema desktop v8 à v32 requis") fail("schema desktop v8 à v33 requis")
proof_path = args.mobile_export proof_path = args.mobile_export
if proof_path is None: if proof_path is None:
candidate = args.artifact.with_name("trainlog-mobile-export-v2.json") 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): def apply_definitions(connection, definitions, complete_causal_envelope=False):
"""Apply validated definitions without owning the surrounding transaction.""" """Apply validated definitions without owning the surrounding transaction."""
supported_versions = range(8, 33) supported_versions = range(8, 34)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
fail("schema desktop v8 à v32 requis") fail("schema desktop v8 à v33 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(): 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") fail("causal equipment protection requires the staged artifact")
imported = skipped = 0 imported = skipped = 0

View file

@ -56,9 +56,9 @@ def load(path):
def apply_aliases(con, aliases): def apply_aliases(con, aliases):
"""Apply parsed aliases inside the caller-owned transaction.""" """Apply parsed aliases inside the caller-owned transaction."""
supported_versions = range(12, 33) supported_versions = range(12, 34)
if con.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: if con.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
fail("schema desktop v12-v32 requis") fail("schema desktop v12-v33 requis")
for source, canonical in aliases: for source, canonical in aliases:
target = con.execute("SELECT id,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?", (canonical,)).fetchone() 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}") 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): def apply_body_zones(connection, parsed, mobile_proof, complete_causal_envelope=False):
"""Apply validated zone rows without owning transaction boundaries.""" """Apply validated zone rows without owning transaction boundaries."""
supported_versions = range(11, 33) supported_versions = range(11, 34)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ImportFailure("schema desktop v11-v32 requis") raise ImportFailure("schema desktop v11-v33 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") 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={} connection.execute("PRAGMA foreign_keys=ON");grouped={}
# CONTRACT: resolve complete alias groups before the first write; direct # 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") 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 parse_timestamp(root["generated_at"], "generated_at");con.row_factory=sqlite3.Row
if con.execute("PRAGMA user_version").fetchone()[0] not in ( if con.execute("PRAGMA user_version").fetchone()[0] not in (
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32): 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33):
fail("schema desktop v17-v32 requis") fail("schema desktop v17-v33 requis")
applied=equal=ancestor=pending=0;seen=set() applied=equal=ancestor=pending=0;seen=set()
for item in root["exercises"]: for item in root["exercises"]:
validate(item);eid=item["exercise_id"] 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 # CONTRACT: v9 owns explicit max_results; earlier supported schemas remain
# readable for legacy artifacts and are never made to fake that table. # readable for legacy artifacts and are never made to fake that table.
if version not in range(5, 33): if version not in range(5, 34):
raise ImportFailure( raise ImportFailure(
f"base desktop schema v5 à v32 attendue, version trouvée: {version}" f"base desktop schema v5 à v33 attendue, version trouvée: {version}"
) )
@ -2002,8 +2002,8 @@ def apply_payload(connection, payload, trace_exercises=False, complete_causal_en
require_supported_schema(connection) require_supported_schema(connection)
schema_version = connection.execute("PRAGMA user_version;").fetchone()[0] schema_version = connection.execute("PRAGMA user_version;").fetchone()[0]
if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32): if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33):
raise ImportFailure("mobile V4 exige le schéma desktop v19 à v32") raise ImportFailure("mobile V4 exige le schéma desktop v19 à v33")
if schema_version >= 20: if schema_version >= 20:
protected = {(row[0], row[1]) for row in connection.execute( protected = {(row[0], row[1]) for row in connection.execute(
"SELECT target_kind,target_id FROM sync_causal_state WHERE deleted=1")} "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): def apply_feedback(db,root,complete_causal_envelope=False):
roots_added=roots_skipped=revisions_added=revisions_skipped=0 roots_added=roots_skipped=revisions_added=revisions_skipped=0
db.execute("PRAGMA foreign_keys=ON") db.execute("PRAGMA foreign_keys=ON")
supported_versions = range(15, 33) supported_versions = range(15, 34)
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
fail("schema desktop v15-v32 requis") fail("schema desktop v15-v33 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") 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() seen=set()
for index,item in enumerate(root["exercise_feedback"]): for index,item in enumerate(root["exercise_feedback"]):

View file

@ -19,6 +19,7 @@ RUNTIME_TOOLS = [
"sync_generation_exchange.py", "sync_generation_exchange.py",
"sleep_diary_exchange.py", "sleep_diary_exchange.py",
"cardio_session_exchange.py", "cardio_session_exchange.py",
"cardio_calibration_exchange.py",
"heart_rate_exchange.py", "heart_rate_exchange.py",
"session_timeline_exchange.py", "session_timeline_exchange.py",
"trainlog_sqlite.py", "trainlog_sqlite.py",
@ -117,8 +118,8 @@ def main() -> int:
["git", "rev-parse", "HEAD"], cwd=ROOT, text=True ["git", "rev-parse", "HEAD"], cwd=ROOT, text=True
).strip(), ).strip(),
"product_version": "0.1.5", "product_version": "0.1.5",
"desktop_schema": 32, "desktop_schema": 33,
"android_schema": 29, "android_schema": 30,
"protocols": [ "protocols": [
"mobile-export-v3", "mobile-export-v3",
"mobile-history-v4", "mobile-history-v4",
@ -134,6 +135,7 @@ def main() -> int:
"program-executions-v1", "program-executions-v1",
"sleep-diary-v1", "sleep-diary-v1",
"cardio-sessions-v1", "cardio-sessions-v1",
"cardio-calibrations-v1",
"heart-rate-v1", "heart-rate-v1",
"session-timeline-v1", "session-timeline-v1",
], ],

View file

@ -87,7 +87,7 @@ def load(path: Path) -> dict:
def apply_executions(db: sqlite3.Connection, root: dict) -> tuple[int, int, int]: def apply_executions(db: sqlite3.Connection, root: dict) -> tuple[int, int, int]:
if db.execute("PRAGMA user_version").fetchone()[0] not in (27, 28, 29, 30, 31, 32): if db.execute("PRAGMA user_version").fetchone()[0] not in (27, 28, 29, 30, 31, 32, 33):
fail("desktop schema v27 through v32 required") fail("desktop schema v27 through v32 required")
added = 0 added = 0
advanced = 0 advanced = 0

View file

@ -184,8 +184,8 @@ def main() -> None:
args = parser.parse_args() args = parser.parse_args()
root = load(args.input) root = load(args.input)
with connect_database(args.database) as db: with connect_database(args.database) as db:
if db.execute("PRAGMA user_version").fetchone()[0] not in (31, 32): if db.execute("PRAGMA user_version").fetchone()[0] not in (31, 32, 33):
fail("desktop schema v31-v32 required") fail("desktop schema v31-v33 required")
db.execute("BEGIN IMMEDIATE") db.execute("BEGIN IMMEDIATE")
apply(db, root) apply(db, root)
db.commit() db.commit()

View file

@ -422,8 +422,8 @@ def main() -> None:
import_command.add_argument("--database", required=True, type=Path) import_command.add_argument("--database", required=True, type=Path)
args = parser.parse_args() args = parser.parse_args()
with connect_database(args.database) as db: with connect_database(args.database) as db:
if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32): if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32, 33):
fail("desktop schema v30-v32 required") fail("desktop schema v30-v33 required")
if args.command == "export": if args.command == "export":
args.output.write_text(json.dumps(build(db), ensure_ascii=False, sort_keys=True, args.output.write_text(json.dumps(build(db), ensure_ascii=False, sort_keys=True,
separators=(",", ":")), encoding="utf-8") separators=(",", ":")), encoding="utf-8")

View file

@ -21,6 +21,7 @@ from pathlib import Path, PurePosixPath
from trainlog_sqlite import connect_database from trainlog_sqlite import connect_database
import causal_delete_exchange import causal_delete_exchange
import cardio_session_exchange import cardio_session_exchange
import cardio_calibration_exchange
import execution_draft_exchange import execution_draft_exchange
import heart_rate_exchange import heart_rate_exchange
import session_timeline_exchange import session_timeline_exchange
@ -85,6 +86,7 @@ ARTIFACTS = (
SUPPORTED = {(row[1], row[2]) for row in ARTIFACTS} | { SUPPORTED = {(row[1], row[2]) for row in ARTIFACTS} | {
("trainlog-program-executions", 1), ("trainlog-program-executions", 1),
("trainlog-cardio-sessions", 1), ("trainlog-cardio-sessions", 1),
("trainlog-cardio-calibrations", 1),
("trainlog-heart-rate", 1), ("trainlog-heart-rate", 1),
("trainlog-session-timeline", 1), ("trainlog-session-timeline", 1),
} }
@ -215,7 +217,7 @@ def validate_manifest_bytes(raw: bytes, expected_consumer: str | None = None):
def require_schema(db: sqlite3.Connection) -> None: def require_schema(db: sqlite3.Connection) -> None:
supported_versions = (24, 25, 26, 27, 28, 29, 30, 31, 32) supported_versions = (24, 25, 26, 27, 28, 29, 30, 31, 32, 33)
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise GenerationError("desktop schema v24, v25, v26 through v32 required") raise GenerationError("desktop schema v24, v25, v26 through v32 required")
@ -681,6 +683,10 @@ def consume_desktop(database: Path, directory: Path) -> dict:
if "sleep-diary" in listed else None) if "sleep-diary" in listed else None)
heart_rate = (heart_rate_exchange.load(listed["heart-rate"]) heart_rate = (heart_rate_exchange.load(listed["heart-rate"])
if "heart-rate" in listed else None) if "heart-rate" in listed else None)
cardio_calibrations = (
cardio_calibration_exchange.load(listed["cardio-calibrations"])
if "cardio-calibrations" in listed else None
)
session_timeline = (session_timeline_exchange.load(listed["session-timeline"]) session_timeline = (session_timeline_exchange.load(listed["session-timeline"])
if "session-timeline" in listed else None) if "session-timeline" in listed else None)
program_executions = ( program_executions = (
@ -734,6 +740,8 @@ def consume_desktop(database: Path, directory: Path) -> dict:
sleep_diary_exchange.apply(db, sleep_diary) sleep_diary_exchange.apply(db, sleep_diary)
if heart_rate is not None: if heart_rate is not None:
heart_rate_exchange.apply(db, heart_rate) heart_rate_exchange.apply(db, heart_rate)
if cardio_calibrations is not None:
cardio_calibration_exchange.apply(db, cardio_calibrations)
if session_timeline is not None: if session_timeline is not None:
session_timeline_exchange.apply(db, session_timeline) session_timeline_exchange.apply(db, session_timeline)
if program_executions is not None: if program_executions is not None:

View file

@ -17,7 +17,7 @@
#include "trainlog/model.h" #include "trainlog/model.h"
#include "trainlog/status.h" #include "trainlog/status.h"
#define TRAINLOG_DATABASE_SCHEMA_VERSION 32 #define TRAINLOG_DATABASE_SCHEMA_VERSION 33
typedef struct TrainlogDatabase TrainlogDatabase; typedef struct TrainlogDatabase TrainlogDatabase;

View file

@ -847,6 +847,14 @@ test_schema_v32_migration = executable(
) )
test('schema_v32_migration', test_schema_v32_migration) test('schema_v32_migration', test_schema_v32_migration)
test_schema_v33_migration = executable(
'test_schema_v33_migration',
'tests/test_schema_v33_migration.c',
dependencies: trainlog_core_dep,
c_args: strict_c_args,
)
test('schema_v33_migration', test_schema_v33_migration)
test_schema_v7_migration = executable( test_schema_v7_migration = executable(
'test_schema_v7_migration', 'test_schema_v7_migration',
'tests/test_schema_v7_migration.c', 'tests/test_schema_v7_migration.c',
@ -941,6 +949,11 @@ test(
find_program('python3'), find_program('python3'),
args: [meson.project_source_root() / 'tests/test_cardio_session_exchange.py'], args: [meson.project_source_root() / 'tests/test_cardio_session_exchange.py'],
) )
test(
'cardio_calibration_exchange',
find_program('python3'),
args: [meson.project_source_root() / 'tests/test_cardio_calibration_exchange.py'],
)
test( test(
'session_preparation_export', 'session_preparation_export',
find_program('python3'), find_program('python3'),

View file

@ -1401,6 +1401,31 @@ static const char *const MIGRATE_V31_TO_V32_SQL =
"BEGIN IMMEDIATE;" "BEGIN IMMEDIATE;"
"PRAGMA user_version=32;COMMIT;"; "PRAGMA user_version=32;COMMIT;";
/* WHY: guided cardio must retain the exact measured calibration reference
* independently from the raw HR samples. CONTRACT: v33 stores only completed
* imported calibration metadata/recovery points; the source curve remains in
* Heart Rate V1. INVARIANT: no session or HR sample is inferred or rewritten. */
static const char *const MIGRATE_V32_TO_V33_SQL =
"BEGIN IMMEDIATE;"
"CREATE TABLE IF NOT EXISTS cardio_calibrations("
"calibration_id TEXT PRIMARY KEY,protocol_version INTEGER NOT NULL CHECK(protocol_version=1),"
"session_id TEXT NOT NULL REFERENCES sessions(session_id) ON DELETE RESTRICT,"
"entry_id TEXT NOT NULL,started_at TEXT NOT NULL,effort_end_at TEXT NOT NULL,"
"ended_at TEXT NOT NULL,heart_rate_capture_id TEXT NOT NULL "
"REFERENCES heart_rate_captures(capture_id) ON DELETE RESTRICT,"
"observed_peak_bpm INTEGER NOT NULL CHECK(observed_peak_bpm BETWEEN 0 AND 65535),"
"imported_at TEXT NOT NULL,CHECK(calibration_id GLOB 'cal_*'),"
"CHECK(session_id GLOB 'se_*'),CHECK(entry_id GLOB 'sxe_*'),"
"CHECK(heart_rate_capture_id GLOB 'hrc_*'));"
"CREATE TABLE IF NOT EXISTS cardio_calibration_recovery("
"calibration_id TEXT NOT NULL REFERENCES cardio_calibrations(calibration_id) ON DELETE CASCADE,"
"target_offset_seconds INTEGER NOT NULL CHECK(target_offset_seconds IN(60,120,180)),"
"observed_at TEXT NOT NULL,bpm INTEGER NOT NULL CHECK(bpm BETWEEN 0 AND 65535),"
"PRIMARY KEY(calibration_id,target_offset_seconds));"
"CREATE INDEX IF NOT EXISTS cardio_calibration_session ON "
"cardio_calibrations(session_id,ended_at,calibration_id);"
"PRAGMA user_version=33;COMMIT;";
static TrainlogStatus ensure_v32_session_kind(TrainlogDatabase *database) { static TrainlogStatus ensure_v32_session_kind(TrainlogDatabase *database) {
sqlite3_stmt *statement = NULL; sqlite3_stmt *statement = NULL;
bool found_kind = false; bool found_kind = false;
@ -2270,7 +2295,7 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
version == 16 || version == 17 || version == 18 || version == 19 || version == 20 || version == 16 || version == 17 || version == 18 || version == 19 || version == 20 ||
version == 21 || version == 22 || version == 23 || version == 24 || version == 25 || 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 || version == 32) { version == 31 || version == 32 || version == 33) {
status = TRAINLOG_STATUS_OK; status = TRAINLOG_STATUS_OK;
} else { } else {
if (version == 1) { if (version == 1) {
@ -2430,6 +2455,9 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
if (status == TRAINLOG_STATUS_OK && version < 32) { if (status == TRAINLOG_STATUS_OK && version < 32) {
status = execute_sql(database, MIGRATE_V31_TO_V32_SQL); status = execute_sql(database, MIGRATE_V31_TO_V32_SQL);
} }
if (status == TRAINLOG_STATUS_OK && version < 33) {
status = execute_sql(database, MIGRATE_V32_TO_V33_SQL);
}
if (status == TRAINLOG_STATUS_OK) { if (status == TRAINLOG_STATUS_OK) {
status = ensure_v18_ai_draft_publication_state(database); status = ensure_v18_ai_draft_publication_state(database);
} }
@ -2449,7 +2477,7 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
if (status != TRAINLOG_STATUS_OK) { if (status != TRAINLOG_STATUS_OK) {
set_open_diagnostic(output_diagnostic, set_open_diagnostic(output_diagnostic,
output_diagnostic_capacity, output_diagnostic_capacity,
version == 0 ? "create schema v32" : "migrate database to schema v32", version == 0 ? "create schema v33" : "migrate database to schema v33",
database->connection, database->connection,
SQLITE_ERROR); SQLITE_ERROR);
(void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL); (void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL);

View file

@ -53,7 +53,7 @@ int main(void) {
trainlog_database_close(production); trainlog_database_close(production);
production = NULL; production = NULL;
CHECK(sqlite3_open(path, &raw) == SQLITE_OK); CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(scalar(raw, "PRAGMA user_version") == 32); CHECK(scalar(raw, "PRAGMA user_version") == 33);
CHECK(scalar(raw, CHECK(scalar(raw,
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN " "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN "
"('sleep_diary_entries','sleep_diary_revisions','sleep_diary_events'," "('sleep_diary_entries','sleep_diary_revisions','sleep_diary_events',"
@ -97,7 +97,7 @@ int main(void) {
trainlog_database_close(production); trainlog_database_close(production);
production = NULL; production = NULL;
CHECK(sqlite3_open(path, &raw) == SQLITE_OK); CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(scalar(raw, "PRAGMA user_version") == 32); CHECK(scalar(raw, "PRAGMA user_version") == 33);
CHECK(scalar(raw, CHECK(scalar(raw,
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name LIKE " "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name LIKE "
"'sleep_medication%'") == 3); "'sleep_medication%'") == 3);

View file

@ -52,7 +52,7 @@ int main(void) {
CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK); CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_schema_version(production, &version) == TRAINLOG_STATUS_OK); CHECK(trainlog_database_schema_version(production, &version) == TRAINLOG_STATUS_OK);
CHECK(version == 32); CHECK(version == 33);
trainlog_database_close(production); trainlog_database_close(production);
production = NULL; production = NULL;

View file

@ -49,7 +49,7 @@ int main(void) {
CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK); CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_schema_version(production, &version) == TRAINLOG_STATUS_OK); CHECK(trainlog_database_schema_version(production, &version) == TRAINLOG_STATUS_OK);
CHECK(version == 32); CHECK(version == 33);
trainlog_database_close(production); trainlog_database_close(production);
production = NULL; production = NULL;

View file

@ -44,7 +44,7 @@ int main(void) {
CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK); CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_schema_version(production, &version) == TRAINLOG_STATUS_OK); CHECK(trainlog_database_schema_version(production, &version) == TRAINLOG_STATUS_OK);
CHECK(version == 32); CHECK(version == 33);
trainlog_database_close(production); trainlog_database_close(production);
production = NULL; production = NULL;

View file

@ -0,0 +1,67 @@
#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-v33-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 cardio_calibration_recovery;"
"DROP TABLE cardio_calibrations;"
"PRAGMA user_version=32;",
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 == 33);
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 IN("
"'cardio_calibrations','cardio_calibration_recovery')") == 2);
CHECK(scalar(raw,
"SELECT COUNT(*) FROM sqlite_master WHERE type='index' "
"AND name='cardio_calibration_session'") == 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 v32 to v33 cardio calibration migration");
return result;
}

View file

@ -178,8 +178,8 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void) {
TRAINLOG_STATUS_DATABASE_ERROR); TRAINLOG_STATUS_DATABASE_ERROR);
CHECK(database == NULL); CHECK(database == NULL);
CHECK(strncmp(diagnostic, CHECK(strncmp(diagnostic,
"migrate database to schema v32: SQLite rc=", "migrate database to schema v33: SQLite rc=",
strlen("migrate database to schema v32: SQLite rc=")) == 0); strlen("migrate database to schema v33: SQLite rc=")) == 0);
CHECK(strstr(diagnostic, "extended_rc=") != NULL); CHECK(strstr(diagnostic, "extended_rc=") != NULL);
CHECK(strstr(diagnostic, "custom_equipment") != NULL); CHECK(strstr(diagnostic, "custom_equipment") != NULL);
CHECK(strstr(diagnostic, "already exists") != NULL); CHECK(strstr(diagnostic, "already exists") != NULL);
@ -197,12 +197,12 @@ static bool test_newer_schema_has_application_diagnostic(void) {
CHECK(fd >= 0); CHECK(fd >= 0);
CHECK(close(fd) == 0); CHECK(close(fd) == 0);
CHECK(sqlite3_open(path, &raw) == SQLITE_OK); CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(sqlite3_exec(raw, "PRAGMA user_version=33;", NULL, NULL, NULL) == SQLITE_OK); CHECK(sqlite3_exec(raw, "PRAGMA user_version=34;", NULL, NULL, NULL) == SQLITE_OK);
CHECK(sqlite3_close(raw) == SQLITE_OK); CHECK(sqlite3_close(raw) == SQLITE_OK);
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) == CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) ==
TRAINLOG_STATUS_SCHEMA_UNSUPPORTED); TRAINLOG_STATUS_SCHEMA_UNSUPPORTED);
CHECK(database == NULL); CHECK(database == NULL);
CHECK(strstr(diagnostic, "schema version 33 is newer") != NULL); CHECK(strstr(diagnostic, "schema version 34 is newer") != NULL);
CHECK(strstr(diagnostic, "SQLite") == NULL); CHECK(strstr(diagnostic, "SQLite") == NULL);
CHECK(unlink(path) == 0); CHECK(unlink(path) == 0);
return true; 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)) == CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) ==
TRAINLOG_STATUS_DATABASE_ERROR); TRAINLOG_STATUS_DATABASE_ERROR);
CHECK(database == NULL); CHECK(database == NULL);
CHECK(strstr(diagnostic, "migrate database to schema v32") != NULL); CHECK(strstr(diagnostic, "migrate database to schema v33") != NULL);
CHECK(strstr(diagnostic, "performed_sets_v9") != NULL); CHECK(strstr(diagnostic, "performed_sets_v9") != NULL);
CHECK(sqlite3_open(path, &raw) == SQLITE_OK); CHECK(sqlite3_open(path, &raw) == SQLITE_OK);