feat(cardio): add measured calibration profile
This commit is contained in:
parent
3ded6ee425
commit
8371d5e16c
74 changed files with 1854 additions and 106 deletions
|
|
@ -46,6 +46,8 @@
|
|||
exact replay is idempotent and identity collision with non-cardio history is
|
||||
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.
|
||||
|
||||
## 0.1.4 — 2026-09-22
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ Programs. Sessions → Programmes remains the technical administration/import,
|
|||
list, detail, archive, and delete surface.
|
||||
Web Exercises administration, the factual Dashboard/Analyse read model, and the
|
||||
synchronized Sleep Diary workspace are the stable v0.1.4 baseline. Version
|
||||
0.1.5 development now has 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
|
||||
|
||||
|
|
|
|||
|
|
@ -225,7 +225,7 @@ internal class AndroidBackupService(
|
|||
manifest.getInt("version") == VERSION &&
|
||||
manifest.getString("package") == context.packageName &&
|
||||
manifest.getBoolean("plaintext") &&
|
||||
manifest.getInt("schema") in 17..29
|
||||
manifest.getInt("schema") in 17..30
|
||||
) {
|
||||
"Unsupported backup."
|
||||
}
|
||||
|
|
@ -319,7 +319,7 @@ internal class AndroidBackupService(
|
|||
}
|
||||
)
|
||||
require(db.rawQuery("PRAGMA foreign_key_check", null).use { !it.moveToFirst() })
|
||||
require(db.version in 17..29) { "Unsupported Android schema ${db.version}." }
|
||||
require(db.version in 17..30) { "Unsupported Android schema ${db.version}." }
|
||||
return db.version
|
||||
} finally {
|
||||
db.close()
|
||||
|
|
|
|||
|
|
@ -72,6 +72,8 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
|||
Kind("trainlog-program-executions", 1, "program-executions-v1.json", false),
|
||||
"sleep-diary" to Kind("trainlog-sleep-diary", 1, "sleep-diary-v1.json", false),
|
||||
"heart-rate" to Kind("trainlog-heart-rate", 1, "heart-rate-v1.json", false),
|
||||
"cardio-calibrations" to
|
||||
Kind("trainlog-cardio-calibrations", 1, "cardio-calibrations-v1.json", false),
|
||||
"session-timeline" to
|
||||
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")),
|
||||
)
|
||||
}
|
||||
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 timelineSessions = timeline.getJSONArray("sessions")
|
||||
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)",
|
||||
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(
|
||||
"UPDATE session_timeline_sessions SET acknowledged_at=? WHERE " +
|
||||
"acknowledged_at IS NULL AND EXISTS(" +
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ import com.labfytools.trainlog.model.ActiveSessionDraft
|
|||
import com.labfytools.trainlog.model.ActiveSessionTimelineContext
|
||||
import com.labfytools.trainlog.model.AiSessionDraft
|
||||
import com.labfytools.trainlog.model.BodyObservationDraft
|
||||
import com.labfytools.trainlog.model.CardioCalibrationPhase
|
||||
import com.labfytools.trainlog.model.CardioCalibrationProfile
|
||||
import com.labfytools.trainlog.model.CardioCalibrationRecoveryPoint
|
||||
import com.labfytools.trainlog.model.BodyObservationSummary
|
||||
import com.labfytools.trainlog.model.ExerciseDataFields
|
||||
import com.labfytools.trainlog.model.ExerciseProfile
|
||||
|
|
@ -65,6 +68,7 @@ import java.io.StringReader
|
|||
import java.nio.ByteBuffer
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.security.MessageDigest
|
||||
import java.time.Duration
|
||||
import java.time.LocalDate
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.temporal.WeekFields
|
||||
|
|
@ -530,6 +534,13 @@ class TrainlogRepository(
|
|||
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 =
|
||||
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(
|
||||
sensorName: String?,
|
||||
observedAt: String,
|
||||
|
|
@ -1714,17 +2164,28 @@ class TrainlogRepository(
|
|||
}
|
||||
}
|
||||
|
||||
return appendHeartRateSample(
|
||||
captureId = current.captureId,
|
||||
observedAt = observedAt,
|
||||
bpm = measurement.bpm,
|
||||
sensorContactDetected = measurement.sensorContactDetected,
|
||||
energyExpended = measurement.energyExpended,
|
||||
rrIntervals =
|
||||
measurement.rrIntervals1024.mapIndexed { index, value ->
|
||||
HeartRateRrInterval(index, value)
|
||||
},
|
||||
)
|
||||
val result =
|
||||
appendHeartRateSample(
|
||||
captureId = current.captureId,
|
||||
observedAt = observedAt,
|
||||
bpm = measurement.bpm,
|
||||
sensorContactDetected = measurement.sensorContactDetected,
|
||||
energyExpended = measurement.energyExpended,
|
||||
rrIntervals =
|
||||
measurement.rrIntervals1024.mapIndexed { 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 =
|
||||
|
|
@ -2221,6 +2682,7 @@ class TrainlogRepository(
|
|||
artifacts["program-executions"] = buildProgramExecutionsV1Json()
|
||||
artifacts["sleep-diary"] = buildSleepDiaryV1Json()
|
||||
artifacts["heart-rate"] = buildHeartRateV1Json()
|
||||
artifacts["cardio-calibrations"] = buildCardioCalibrationsV1Json()
|
||||
artifacts["session-timeline"] = buildSessionTimelineV1Json()
|
||||
artifacts
|
||||
}
|
||||
|
|
@ -2746,6 +3208,8 @@ class TrainlogRepository(
|
|||
val normalizedPrefix = normalizeName(query)
|
||||
val selection = mutableListOf<String>()
|
||||
val arguments = mutableListOf<String>()
|
||||
selection += "e.exercise_id<>?"
|
||||
arguments += CARDIO_CALIBRATION_EXERCISE_ID
|
||||
if (normalizedPrefix.isNotEmpty()) {
|
||||
selection += "e.normalized_name LIKE ? ESCAPE '\\'"
|
||||
arguments += normalizedPrefix
|
||||
|
|
@ -3078,8 +3542,9 @@ class TrainlogRepository(
|
|||
"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.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",
|
||||
null,
|
||||
"FROM exercises e JOIN exercise_profile_state s ON s.exercise_row_id=e.id " +
|
||||
"WHERE e.exercise_id<>? ORDER BY e.exercise_id",
|
||||
arrayOf(CARDIO_CALIBRATION_EXERCISE_ID),
|
||||
).use { c ->
|
||||
while (c.moveToNext()) {
|
||||
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")
|
||||
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
|
||||
* may resend a retired catalogue ID; resolve it before lookup
|
||||
* so the old row can never be resurrected. */
|
||||
|
|
@ -9862,6 +10332,22 @@ class TrainlogRepository(
|
|||
allowTargetOnly: Boolean = false,
|
||||
): Boolean {
|
||||
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
|
||||
if (maxWeight != null) {
|
||||
/* 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_SESSION_TIMELINE_SESSIONS_PER_GENERATION = 256
|
||||
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_TARGET_SETS = 99
|
||||
private const val MAX_AI_TARGET_REPS = 999
|
||||
|
|
@ -10692,7 +11186,7 @@ private class TrainlogDatabaseHelper(
|
|||
appContext,
|
||||
databaseName,
|
||||
null,
|
||||
29,
|
||||
30,
|
||||
) {
|
||||
override fun onConfigure(
|
||||
db: SQLiteDatabase,
|
||||
|
|
@ -10748,6 +11242,8 @@ private class TrainlogDatabaseHelper(
|
|||
createSleepDiaryTables(db)
|
||||
createHeartRateTables(db)
|
||||
createSessionTimelineTables(db)
|
||||
createCardioCalibrationTables(db)
|
||||
ensureCardioCalibrationExercise(db)
|
||||
seedEquipment(db)
|
||||
}
|
||||
|
||||
|
|
@ -11004,6 +11500,16 @@ private class TrainlogDatabaseHelper(
|
|||
)
|
||||
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) {
|
||||
error(
|
||||
|
|
@ -11142,6 +11648,85 @@ private class TrainlogDatabaseHelper(
|
|||
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) {
|
||||
db.execSQL(
|
||||
"""CREATE TABLE IF NOT EXISTS heart_rate_captures(
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
)
|
||||
|
|
@ -69,6 +69,7 @@ fun AndroidAppShell(
|
|||
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.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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ sealed interface AppRoute {
|
|||
data object AiSessionDrafts : 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 CardioCalibration : 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 SessionCorrection(val sessionId: String) : AppRoute { override val section = AppSection.SESSIONS }
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -58,6 +58,7 @@ fun SessionsHub(
|
|||
onDrafts: () -> Unit,
|
||||
onHistory: () -> Unit,
|
||||
onPrograms: () -> Unit = {},
|
||||
onCardioCalibration: () -> Unit = {},
|
||||
) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
val strings = localizedContext()
|
||||
|
|
@ -77,6 +78,13 @@ fun SessionsHub(
|
|||
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,
|
||||
Modifier.fillMaxWidth().testTag("sessions-history-action"))
|
||||
Spacer(Modifier.height(16.dp).testTag("sessions-programs-spacing"))
|
||||
|
|
|
|||
|
|
@ -103,8 +103,16 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
|
|||
},
|
||||
{ open(AppRoute.BodyMeasurements) }, { open(AppRoute.Sync) },
|
||||
)
|
||||
AppRoute.Sessions -> SessionsHub(activeDraft, pendingAiDraftCount, { open(AppRoute.SessionEditor) }, ::openManualSession,
|
||||
{ open(AppRoute.AiSessionDrafts) }, { open(AppRoute.CompletedSessions) }, { open(AppRoute.Programs) })
|
||||
AppRoute.Sessions -> SessionsHub(
|
||||
activeDraft,
|
||||
pendingAiDraftCount,
|
||||
{ open(AppRoute.SessionEditor) },
|
||||
::openManualSession,
|
||||
{ open(AppRoute.AiSessionDrafts) },
|
||||
{ open(AppRoute.CompletedSessions) },
|
||||
{ open(AppRoute.Programs) },
|
||||
{ open(AppRoute.CardioCalibration) },
|
||||
)
|
||||
AppRoute.SessionEditor -> SessionScreen(
|
||||
repository,
|
||||
catalogRevision,
|
||||
|
|
@ -137,6 +145,10 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
|
|||
)
|
||||
AppRoute.CompletedSessions -> HistoryScreen(repository, { back() }) { open(AppRoute.SessionDetail(it)) }
|
||||
AppRoute.Programs -> ProgramsScreen(repository) { open(AppRoute.ProgramDetail(it)) }
|
||||
AppRoute.CardioCalibration -> CardioCalibrationScreen(repository) {
|
||||
draftRevision++
|
||||
exportSnapshot()
|
||||
}
|
||||
is AppRoute.ProgramDetail -> ProgramDetailScreen(repository, route.programId) {
|
||||
draftRevision++
|
||||
open(AppRoute.SessionEditor)
|
||||
|
|
|
|||
|
|
@ -560,4 +560,29 @@
|
|||
<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="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>
|
||||
|
|
|
|||
|
|
@ -570,4 +570,29 @@
|
|||
<string name="sleep_quick_conflict">La nuit a changé ; rechargez avant de recommencer.</string>
|
||||
<string name="sleep_quick_error">Enregistrement impossible.</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>
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ class AndroidV10PlanningMigrationTest {
|
|||
|
||||
TrainlogRepository(context, name).useForTest { it.listSessions() }
|
||||
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")) {
|
||||
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"))
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ class AndroidV11ExerciseAliasMigrationTest {
|
|||
migrated.close()
|
||||
}
|
||||
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
|
||||
* 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") }
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ class AndroidV16AiSessionDraftMigrationTest {
|
|||
migrated.close()
|
||||
}
|
||||
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(
|
||||
scalar(db, "SELECT COUNT(*) FROM sessions"),
|
||||
scalar(db, "SELECT COUNT(*) FROM performed_sets"),
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ class AndroidV25ProgramExecutionMigrationTest {
|
|||
reopened.close()
|
||||
}
|
||||
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("ok", text(db, "PRAGMA integrity_check"))
|
||||
db.rawQuery("PRAGMA foreign_key_check", null).use { cursor ->
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ class AndroidV26SleepMigrationTest {
|
|||
context.getDatabasePath(name).path,
|
||||
null,
|
||||
SQLiteDatabase.OPEN_READONLY,
|
||||
).use { assertEquals(29, it.version) }
|
||||
).use { assertEquals(30, it.version) }
|
||||
} finally {
|
||||
repository.close()
|
||||
context.deleteDatabase(name)
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ class AndroidV27HeartRateMigrationTest {
|
|||
context.getDatabasePath(name).path,
|
||||
null,
|
||||
SQLiteDatabase.OPEN_READONLY,
|
||||
).use { assertEquals(29, it.version) }
|
||||
).use { assertEquals(30, it.version) }
|
||||
} finally {
|
||||
repository.close()
|
||||
context.deleteDatabase(name)
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ class AndroidV28SessionTimelineMigrationTest {
|
|||
null,
|
||||
SQLiteDatabase.OPEN_READONLY,
|
||||
).use { db ->
|
||||
assertEquals(29, db.version)
|
||||
assertEquals(30, db.version)
|
||||
db.rawQuery(
|
||||
"SELECT session_id,started_at,ended_at FROM session_timeline_sessions",
|
||||
null,
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ class AndroidV29CardioSessionMigrationTest {
|
|||
null,
|
||||
SQLiteDatabase.OPEN_READONLY,
|
||||
).use { db ->
|
||||
assertEquals(29, db.version)
|
||||
assertEquals(30, db.version)
|
||||
db.rawQuery(
|
||||
"SELECT session_type,session_kind FROM sessions",
|
||||
null,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -328,7 +328,7 @@ class BodyZonesTest {
|
|||
SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null,
|
||||
SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||
db.rawQuery("PRAGMA user_version", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst()); assertEquals(29, cursor.getInt(0))
|
||||
assertTrue(cursor.moveToFirst()); assertEquals(30, cursor.getInt(0))
|
||||
}
|
||||
db.rawQuery("SELECT id FROM exercises", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0))
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ class BridgeBackupMigrationTest {
|
|||
null,
|
||||
android.database.sqlite.SQLiteDatabase.OPEN_READONLY,
|
||||
)
|
||||
.use { assertEquals(29, it.version) }
|
||||
.use { assertEquals(30, it.version) }
|
||||
} finally {
|
||||
repository.close()
|
||||
context.deleteDatabase(name)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -360,7 +360,7 @@ class SessionPreparationExchangeTest {
|
|||
repository = TrainlogRepository(context, name)
|
||||
assertEquals(1, repository.listPreparedSessions().size)
|
||||
repository.close()
|
||||
assertEquals("29", scalarText(name, "PRAGMA user_version"))
|
||||
assertEquals("30", scalarText(name, "PRAGMA user_version"))
|
||||
assertEquals(
|
||||
"0",
|
||||
scalarText(name, "SELECT COUNT(*) FROM session_preparation_withdrawals"),
|
||||
|
|
|
|||
|
|
@ -509,7 +509,7 @@ class SyncGenerationServiceTest {
|
|||
.use { db ->
|
||||
db.rawQuery("PRAGMA user_version", null).use {
|
||||
assertTrue(it.moveToFirst())
|
||||
assertEquals(29, it.getInt(0))
|
||||
assertEquals(30, it.getInt(0))
|
||||
}
|
||||
db.rawQuery("SELECT COUNT(*) FROM exercises", null).use {
|
||||
assertTrue(it.moveToFirst())
|
||||
|
|
|
|||
|
|
@ -2260,7 +2260,7 @@ class TrainlogRepositoryDraftTest {
|
|||
).use { db ->
|
||||
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
assertEquals(29, cursor.getInt(0))
|
||||
assertEquals(30, cursor.getInt(0))
|
||||
}
|
||||
db.rawQuery(
|
||||
"SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " +
|
||||
|
|
@ -2387,7 +2387,7 @@ class TrainlogRepositoryDraftTest {
|
|||
).use { db ->
|
||||
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
assertEquals(29, cursor.getInt(0))
|
||||
assertEquals(30, cursor.getInt(0))
|
||||
}
|
||||
db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
|
|
|
|||
|
|
@ -112,8 +112,8 @@ parallel implementation of its rules.
|
|||
| Boundary | Current state |
|
||||
|---|---|
|
||||
| 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 |
|
||||
| Android SQLite | schema v29; canonical `session_kind` adds true cardio sessions while legacy draft/history codecs remain training/max_test |
|
||||
| Desktop SQLite | schema v33; versioned cardio calibration profiles/recovery are additive on top of canonical cardio session kind |
|
||||
| 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 |
|
||||
| Desktop terminal backend | Notcurses only |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` |
|
||||
| Synchronization characterization | `TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` |
|
||||
|
|
|
|||
|
|
@ -1,5 +1,13 @@
|
|||
# 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 adds the product-owned `sessions.session_kind` column with the
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ sensor. Heart-rate ingestion must not reuse or overload the frozen Trainlog
|
|||
Bluetooth synchronization transport contract.
|
||||
|
||||
```text
|
||||
CURRENT_OPERATIONAL_CURSOR=TRAINLOG_CARDIO_CALIBRATION_V1
|
||||
CURRENT_OPERATIONAL_CURSOR=TRAINLOG_CARDIO_BPM_GUIDANCE_V1
|
||||
```
|
||||
|
||||
The focused `TRAINLOG_SYNC_CAUSAL_DELETE_V1_CLOSEOUT` is complete before this
|
||||
|
|
|
|||
|
|
@ -1127,7 +1127,15 @@ facts are applied.
|
|||
Programs, manual preparations, AI execution drafts, mobile V3/V4 and
|
||||
`TRAINLOG_FORMAT_V1` are not widened by this companion.
|
||||
|
||||
## 22. Legacy active-protocol limitations
|
||||
## 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.
|
||||
`POST /api/v1/sync` admits one correlated run and `GET /api/v1/sync/status` is
|
||||
|
|
|
|||
|
|
@ -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
|
||||
**277 tests: 272 passed, five skipped, zero failures/errors**; `assembleDebug`
|
||||
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.
|
||||
|
|
|
|||
175
tests/test_cardio_calibration_exchange.py
Normal file
175
tests/test_cardio_calibration_exchange.py
Normal 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()
|
||||
|
|
@ -66,13 +66,14 @@ class DeploymentToolsTest(unittest.TestCase):
|
|||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
inventory = json.loads((output / "candidate-inventory.json").read_text())
|
||||
self.assertEqual(inventory["product_version"], "0.1.5")
|
||||
self.assertEqual(inventory["desktop_schema"], 32)
|
||||
self.assertEqual(inventory["android_schema"], 29)
|
||||
self.assertEqual(inventory["desktop_schema"], 33)
|
||||
self.assertEqual(inventory["android_schema"], 30)
|
||||
self.assertIn("session-preparations-v2", inventory["protocols"])
|
||||
self.assertIn("programs-v1", inventory["protocols"])
|
||||
self.assertIn("program-executions-v1", inventory["protocols"])
|
||||
self.assertIn("sleep-diary-v1", inventory["protocols"])
|
||||
self.assertIn("cardio-sessions-v1", inventory["protocols"])
|
||||
self.assertIn("cardio-calibrations-v1", inventory["protocols"])
|
||||
self.assertIn("heart-rate-v1", inventory["protocols"])
|
||||
self.assertIn("session-timeline-v1", inventory["protocols"])
|
||||
self.assertIn("bluetooth-files-v1", inventory["protocols"])
|
||||
|
|
|
|||
282
tools/cardio_calibration_exchange.py
Normal file
282
tools/cardio_calibration_exchange.py
Normal 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()
|
||||
|
|
@ -38,8 +38,8 @@ def digest(value) -> str:
|
|||
|
||||
def require_schema(db: sqlite3.Connection) -> None:
|
||||
version = db.execute("PRAGMA user_version").fetchone()[0]
|
||||
if version not in (20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32):
|
||||
raise CausalError(f"desktop schema v20-v32 required, found v{version}")
|
||||
if version not in (20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33):
|
||||
raise CausalError(f"desktop schema v20-v33 required, found v{version}")
|
||||
|
||||
|
||||
def target_snapshot(db: sqlite3.Connection, kind: str, target: str):
|
||||
|
|
|
|||
|
|
@ -182,8 +182,8 @@ def causal_payload(draft):
|
|||
|
||||
def require_schema(connection):
|
||||
version = connection.execute("PRAGMA user_version").fetchone()[0]
|
||||
if version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32):
|
||||
raise LifecycleError(f"desktop schema v19-v32 required, found v{version}")
|
||||
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-v33 required, found v{version}")
|
||||
|
||||
|
||||
def import_document(connection, document, own_transaction=True):
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ from pathlib import Path
|
|||
from trainlog_sqlite import configure_connection
|
||||
|
||||
|
||||
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 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 = (
|
||||
"body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm",
|
||||
"hips_cm", "left_arm_cm", "right_arm_cm", "left_forearm_cm",
|
||||
|
|
@ -125,7 +125,7 @@ def build_export(connection, generated_at):
|
|||
"""Build the raw export using only explicitly ordered SELECT statements."""
|
||||
schema_version = connection.execute("PRAGMA user_version").fetchone()[0]
|
||||
if schema_version not in SUPPORTED_SCHEMA_VERSIONS:
|
||||
raise ValueError("schema desktop v15-v32 requis")
|
||||
raise ValueError("schema desktop v15-v33 requis")
|
||||
|
||||
root = {
|
||||
"format": "TRAINLOG_AI_EXPORT",
|
||||
|
|
|
|||
|
|
@ -20,9 +20,9 @@ ID_PREFIX = "aid_"
|
|||
def ensure_publication_state(connection):
|
||||
"""Install the additive v18 cursor for direct syncd/tool invocations."""
|
||||
owned_transaction = not connection.in_transaction
|
||||
supported_versions = (18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 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:
|
||||
raise ValueError("schema desktop v18-v32 requis")
|
||||
raise ValueError("schema desktop v18-v33 requis")
|
||||
columns = {row[1] for row in connection.execute(
|
||||
"PRAGMA table_info(ai_session_drafts)")}
|
||||
if "published_at" not in columns:
|
||||
|
|
|
|||
|
|
@ -28,9 +28,9 @@ def main():
|
|||
args = parser.parse_args()
|
||||
connection = connect_database(args.database)
|
||||
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:
|
||||
raise ValueError("schema desktop v8 à v32 requis")
|
||||
raise ValueError("schema desktop v8 à v33 requis")
|
||||
known_equipment = load_supplied_equipment_ids(args.catalog)
|
||||
known_equipment.update(row[0] for row in connection.execute(
|
||||
"SELECT equipment_id FROM custom_equipment"))
|
||||
|
|
|
|||
|
|
@ -24,9 +24,9 @@ def main(complete_causal_envelope=False):
|
|||
connection = connect_database(args.database)
|
||||
connection.row_factory = sqlite3.Row
|
||||
try:
|
||||
supported_versions = tuple(range(8, 33))
|
||||
supported_versions = tuple(range(8, 34))
|
||||
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():
|
||||
raise ValueError("causal equipment protection requires the staged artifact")
|
||||
equipment = [dict(row) for row in connection.execute(
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@ def main():
|
|||
con = connect_database(args.database)
|
||||
try:
|
||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (
|
||||
12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32):
|
||||
raise ValueError("schema desktop v12-v32 requis")
|
||||
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-v33 requis")
|
||||
rows = con.execute(
|
||||
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "
|
||||
"ORDER BY source_exercise_id COLLATE BINARY"
|
||||
|
|
|
|||
|
|
@ -41,9 +41,9 @@ def main(complete_causal_envelope=False):
|
|||
connection = connect_database(args.database)
|
||||
connection.row_factory = sqlite3.Row
|
||||
try:
|
||||
supported_versions = tuple(range(11, 33))
|
||||
supported_versions = tuple(range(11, 34))
|
||||
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():
|
||||
raise ValueError("causal BODY ZONES protection requires the staged artifact")
|
||||
exercises = []
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ def main():
|
|||
con.row_factory = sqlite3.Row
|
||||
try:
|
||||
version = con.execute("PRAGMA user_version").fetchone()[0]
|
||||
if version not in (17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 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}")
|
||||
rows = con.execute("""
|
||||
SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields,
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ def main():
|
|||
# CONTRACT: v8 adds only desktop-local custom equipment. The PC
|
||||
# catalogue artifact is unchanged, but it must read the current
|
||||
# canonical desktop schema rather than accept a stale pre-v8 database.
|
||||
if version not in range(8, 33):
|
||||
if version not in range(8, 34):
|
||||
raise SystemExit(
|
||||
"PC_CATALOG_EXPORT=FAIL "
|
||||
f"schema={version}"
|
||||
|
|
|
|||
|
|
@ -83,12 +83,12 @@ def main(complete_causal_envelope=False):
|
|||
con.row_factory = sqlite3.Row
|
||||
try:
|
||||
schema_version = con.execute("PRAGMA user_version").fetchone()[0]
|
||||
if args.version == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 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")
|
||||
supported_versions = range(11, 33)
|
||||
supported_versions = range(11, 34)
|
||||
legacy_v2_schema = args.version == 2 and schema_version == 10
|
||||
if schema_version not in supported_versions and not legacy_v2_schema:
|
||||
raise ValueError("schema desktop v11-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(
|
||||
"SELECT 1 FROM sync_causal_state WHERE deleted=1 LIMIT 1").fetchone():
|
||||
raise ValueError("causal protection refuses a mobile snapshot that omits tombstones")
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ def nullable(row, index):
|
|||
|
||||
def export_programs(database: Path) -> dict:
|
||||
with closing(connect_database(database)) as db:
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (26, 27, 28, 29, 30, 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")
|
||||
programs = []
|
||||
for row in db.execute(
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ MAX_WITHDRAWALS = 128
|
|||
|
||||
|
||||
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:
|
||||
raise ValueError("desktop schema v24, v25, v26 through v32 required")
|
||||
deliveries = []
|
||||
|
|
|
|||
|
|
@ -10,9 +10,9 @@ def main(complete_causal_envelope=False):
|
|||
parser=argparse.ArgumentParser(); parser.add_argument("output",type=Path); parser.add_argument("--database",type=Path,required=True); args=parser.parse_args()
|
||||
db=connect_database(args.database)
|
||||
try:
|
||||
supported_versions = range(15, 33)
|
||||
supported_versions = range(15, 34)
|
||||
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")
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -236,8 +236,8 @@ def main() -> None:
|
|||
args = parser.parse_args()
|
||||
root = load(args.input)
|
||||
with connect_database(args.database) as db:
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32):
|
||||
fail("desktop schema v30-v32 required")
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32, 33):
|
||||
fail("desktop schema v30-v33 required")
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
apply(db, root)
|
||||
db.commit()
|
||||
|
|
|
|||
|
|
@ -193,9 +193,9 @@ def import_payload(connection, payload, imported_at=None):
|
|||
INVARIANT: validation and every child insert share one IMMEDIATE transaction;
|
||||
any failure leaves no draft, child, or import-identity row behind.
|
||||
"""
|
||||
supported_versions = range(18, 33)
|
||||
supported_versions = range(18, 34)
|
||||
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)
|
||||
digest = canonical_digest(payload)
|
||||
imported_at = imported_at or dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
|
||||
|
|
|
|||
|
|
@ -206,9 +206,9 @@ def main():
|
|||
payload = json.loads(args.artifact.read_text(encoding="utf-8"))
|
||||
connection = connect_database(args.database)
|
||||
try:
|
||||
supported_versions = range(8, 33)
|
||||
supported_versions = range(8, 34)
|
||||
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
|
||||
if proof_path is None:
|
||||
candidate = args.artifact.with_name("trainlog-mobile-export-v2.json")
|
||||
|
|
|
|||
|
|
@ -53,9 +53,9 @@ def validate(payload, reserved):
|
|||
|
||||
def apply_definitions(connection, definitions, complete_causal_envelope=False):
|
||||
"""Apply validated definitions without owning the surrounding transaction."""
|
||||
supported_versions = range(8, 33)
|
||||
supported_versions = range(8, 34)
|
||||
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():
|
||||
fail("causal equipment protection requires the staged artifact")
|
||||
imported = skipped = 0
|
||||
|
|
|
|||
|
|
@ -56,9 +56,9 @@ def load(path):
|
|||
|
||||
def apply_aliases(con, aliases):
|
||||
"""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:
|
||||
fail("schema desktop v12-v32 requis")
|
||||
fail("schema desktop v12-v33 requis")
|
||||
for source, canonical in aliases:
|
||||
target = con.execute("SELECT id,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?", (canonical,)).fetchone()
|
||||
if target is None: fail(f"cible canonique absente: {canonical}")
|
||||
|
|
|
|||
|
|
@ -151,9 +151,9 @@ def replace(connection, row_id, primary, secondary):
|
|||
|
||||
def apply_body_zones(connection, parsed, mobile_proof, complete_causal_envelope=False):
|
||||
"""Apply validated zone rows without owning transaction boundaries."""
|
||||
supported_versions = range(11, 33)
|
||||
supported_versions = range(11, 34)
|
||||
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")
|
||||
connection.execute("PRAGMA foreign_keys=ON");grouped={}
|
||||
# CONTRACT: resolve complete alias groups before the first write; direct
|
||||
|
|
|
|||
|
|
@ -50,8 +50,8 @@ def apply_profile_state(con, root, allow_pending=False):
|
|||
if not isinstance(root,dict) or set(root)!={"format","version","generated_at","exercises"} or root["format"]!="trainlog-exercise-profile-state" or type(root["version"]) is not int or root["version"]!=1 or not isinstance(root["exercises"],list): fail("enveloppe profile-state invalide")
|
||||
parse_timestamp(root["generated_at"], "generated_at");con.row_factory=sqlite3.Row
|
||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (
|
||||
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32):
|
||||
fail("schema desktop v17-v32 requis")
|
||||
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33):
|
||||
fail("schema desktop v17-v33 requis")
|
||||
applied=equal=ancestor=pending=0;seen=set()
|
||||
for item in root["exercises"]:
|
||||
validate(item);eid=item["exercise_id"]
|
||||
|
|
|
|||
|
|
@ -847,9 +847,9 @@ def require_supported_schema(connection):
|
|||
|
||||
# CONTRACT: v9 owns explicit max_results; earlier supported schemas remain
|
||||
# readable for legacy artifacts and are never made to fake that table.
|
||||
if version not in range(5, 33):
|
||||
if version not in range(5, 34):
|
||||
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)
|
||||
|
||||
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):
|
||||
raise ImportFailure("mobile V4 exige le schéma desktop v19 à v32")
|
||||
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 à v33")
|
||||
if schema_version >= 20:
|
||||
protected = {(row[0], row[1]) for row in connection.execute(
|
||||
"SELECT target_kind,target_id FROM sync_causal_state WHERE deleted=1")}
|
||||
|
|
|
|||
|
|
@ -52,9 +52,9 @@ def merge_revisions(db,table,parent_column,parent_id,revisions):
|
|||
def apply_feedback(db,root,complete_causal_envelope=False):
|
||||
roots_added=roots_skipped=revisions_added=revisions_skipped=0
|
||||
db.execute("PRAGMA foreign_keys=ON")
|
||||
supported_versions = range(15, 33)
|
||||
supported_versions = range(15, 34)
|
||||
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")
|
||||
seen=set()
|
||||
for index,item in enumerate(root["exercise_feedback"]):
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ RUNTIME_TOOLS = [
|
|||
"sync_generation_exchange.py",
|
||||
"sleep_diary_exchange.py",
|
||||
"cardio_session_exchange.py",
|
||||
"cardio_calibration_exchange.py",
|
||||
"heart_rate_exchange.py",
|
||||
"session_timeline_exchange.py",
|
||||
"trainlog_sqlite.py",
|
||||
|
|
@ -117,8 +118,8 @@ def main() -> int:
|
|||
["git", "rev-parse", "HEAD"], cwd=ROOT, text=True
|
||||
).strip(),
|
||||
"product_version": "0.1.5",
|
||||
"desktop_schema": 32,
|
||||
"android_schema": 29,
|
||||
"desktop_schema": 33,
|
||||
"android_schema": 30,
|
||||
"protocols": [
|
||||
"mobile-export-v3",
|
||||
"mobile-history-v4",
|
||||
|
|
@ -134,6 +135,7 @@ def main() -> int:
|
|||
"program-executions-v1",
|
||||
"sleep-diary-v1",
|
||||
"cardio-sessions-v1",
|
||||
"cardio-calibrations-v1",
|
||||
"heart-rate-v1",
|
||||
"session-timeline-v1",
|
||||
],
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ def load(path: Path) -> dict:
|
|||
|
||||
|
||||
def apply_executions(db: sqlite3.Connection, root: dict) -> tuple[int, int, int]:
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (27, 28, 29, 30, 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")
|
||||
added = 0
|
||||
advanced = 0
|
||||
|
|
|
|||
|
|
@ -184,8 +184,8 @@ def main() -> None:
|
|||
args = parser.parse_args()
|
||||
root = load(args.input)
|
||||
with connect_database(args.database) as db:
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (31, 32):
|
||||
fail("desktop schema v31-v32 required")
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (31, 32, 33):
|
||||
fail("desktop schema v31-v33 required")
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
apply(db, root)
|
||||
db.commit()
|
||||
|
|
|
|||
|
|
@ -422,8 +422,8 @@ def main() -> None:
|
|||
import_command.add_argument("--database", required=True, type=Path)
|
||||
args = parser.parse_args()
|
||||
with connect_database(args.database) as db:
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32):
|
||||
fail("desktop schema v30-v32 required")
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32, 33):
|
||||
fail("desktop schema v30-v33 required")
|
||||
if args.command == "export":
|
||||
args.output.write_text(json.dumps(build(db), ensure_ascii=False, sort_keys=True,
|
||||
separators=(",", ":")), encoding="utf-8")
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ from pathlib import Path, PurePosixPath
|
|||
from trainlog_sqlite import connect_database
|
||||
import causal_delete_exchange
|
||||
import cardio_session_exchange
|
||||
import cardio_calibration_exchange
|
||||
import execution_draft_exchange
|
||||
import heart_rate_exchange
|
||||
import session_timeline_exchange
|
||||
|
|
@ -85,6 +86,7 @@ ARTIFACTS = (
|
|||
SUPPORTED = {(row[1], row[2]) for row in ARTIFACTS} | {
|
||||
("trainlog-program-executions", 1),
|
||||
("trainlog-cardio-sessions", 1),
|
||||
("trainlog-cardio-calibrations", 1),
|
||||
("trainlog-heart-rate", 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:
|
||||
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:
|
||||
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)
|
||||
heart_rate = (heart_rate_exchange.load(listed["heart-rate"])
|
||||
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"])
|
||||
if "session-timeline" in listed else None)
|
||||
program_executions = (
|
||||
|
|
@ -734,6 +740,8 @@ def consume_desktop(database: Path, directory: Path) -> dict:
|
|||
sleep_diary_exchange.apply(db, sleep_diary)
|
||||
if heart_rate is not None:
|
||||
heart_rate_exchange.apply(db, heart_rate)
|
||||
if cardio_calibrations is not None:
|
||||
cardio_calibration_exchange.apply(db, cardio_calibrations)
|
||||
if session_timeline is not None:
|
||||
session_timeline_exchange.apply(db, session_timeline)
|
||||
if program_executions is not None:
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
#include "trainlog/model.h"
|
||||
#include "trainlog/status.h"
|
||||
|
||||
#define TRAINLOG_DATABASE_SCHEMA_VERSION 32
|
||||
#define TRAINLOG_DATABASE_SCHEMA_VERSION 33
|
||||
|
||||
typedef struct TrainlogDatabase TrainlogDatabase;
|
||||
|
||||
|
|
|
|||
|
|
@ -847,6 +847,14 @@ test_schema_v32_migration = executable(
|
|||
)
|
||||
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',
|
||||
'tests/test_schema_v7_migration.c',
|
||||
|
|
@ -941,6 +949,11 @@ test(
|
|||
find_program('python3'),
|
||||
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(
|
||||
'session_preparation_export',
|
||||
find_program('python3'),
|
||||
|
|
|
|||
|
|
@ -1401,6 +1401,31 @@ static const char *const MIGRATE_V31_TO_V32_SQL =
|
|||
"BEGIN IMMEDIATE;"
|
||||
"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) {
|
||||
sqlite3_stmt *statement = NULL;
|
||||
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 == 21 || version == 22 || version == 23 || version == 24 || version == 25 ||
|
||||
version == 26 || version == 27 || version == 28 || version == 29 || version == 30 ||
|
||||
version == 31 || version == 32) {
|
||||
version == 31 || version == 32 || version == 33) {
|
||||
status = TRAINLOG_STATUS_OK;
|
||||
} else {
|
||||
if (version == 1) {
|
||||
|
|
@ -2430,6 +2455,9 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
|
|||
if (status == TRAINLOG_STATUS_OK && version < 32) {
|
||||
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) {
|
||||
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) {
|
||||
set_open_diagnostic(output_diagnostic,
|
||||
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,
|
||||
SQLITE_ERROR);
|
||||
(void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL);
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ int main(void) {
|
|||
trainlog_database_close(production);
|
||||
production = NULL;
|
||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||
CHECK(scalar(raw, "PRAGMA user_version") == 32);
|
||||
CHECK(scalar(raw, "PRAGMA user_version") == 33);
|
||||
CHECK(scalar(raw,
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN "
|
||||
"('sleep_diary_entries','sleep_diary_revisions','sleep_diary_events',"
|
||||
|
|
@ -97,7 +97,7 @@ int main(void) {
|
|||
trainlog_database_close(production);
|
||||
production = NULL;
|
||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||
CHECK(scalar(raw, "PRAGMA user_version") == 32);
|
||||
CHECK(scalar(raw, "PRAGMA user_version") == 33);
|
||||
CHECK(scalar(raw,
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name LIKE "
|
||||
"'sleep_medication%'") == 3);
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ int main(void) {
|
|||
|
||||
CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_schema_version(production, &version) == TRAINLOG_STATUS_OK);
|
||||
CHECK(version == 32);
|
||||
CHECK(version == 33);
|
||||
trainlog_database_close(production);
|
||||
production = NULL;
|
||||
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ int main(void) {
|
|||
|
||||
CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_schema_version(production, &version) == TRAINLOG_STATUS_OK);
|
||||
CHECK(version == 32);
|
||||
CHECK(version == 33);
|
||||
trainlog_database_close(production);
|
||||
production = NULL;
|
||||
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ int main(void) {
|
|||
|
||||
CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_schema_version(production, &version) == TRAINLOG_STATUS_OK);
|
||||
CHECK(version == 32);
|
||||
CHECK(version == 33);
|
||||
trainlog_database_close(production);
|
||||
production = NULL;
|
||||
|
||||
|
|
|
|||
67
tui/tests/test_schema_v33_migration.c
Normal file
67
tui/tests/test_schema_v33_migration.c
Normal 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;
|
||||
}
|
||||
|
|
@ -178,8 +178,8 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void) {
|
|||
TRAINLOG_STATUS_DATABASE_ERROR);
|
||||
CHECK(database == NULL);
|
||||
CHECK(strncmp(diagnostic,
|
||||
"migrate database to schema v32: SQLite rc=",
|
||||
strlen("migrate database to schema v32: SQLite rc=")) == 0);
|
||||
"migrate database to schema v33: SQLite rc=",
|
||||
strlen("migrate database to schema v33: SQLite rc=")) == 0);
|
||||
CHECK(strstr(diagnostic, "extended_rc=") != NULL);
|
||||
CHECK(strstr(diagnostic, "custom_equipment") != NULL);
|
||||
CHECK(strstr(diagnostic, "already exists") != NULL);
|
||||
|
|
@ -197,12 +197,12 @@ static bool test_newer_schema_has_application_diagnostic(void) {
|
|||
CHECK(fd >= 0);
|
||||
CHECK(close(fd) == 0);
|
||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||
CHECK(sqlite3_exec(raw, "PRAGMA user_version=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(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) ==
|
||||
TRAINLOG_STATUS_SCHEMA_UNSUPPORTED);
|
||||
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(unlink(path) == 0);
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -154,7 +154,7 @@ static bool test_v9_to_v10_failure_rolls_back(void) {
|
|||
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) ==
|
||||
TRAINLOG_STATUS_DATABASE_ERROR);
|
||||
CHECK(database == NULL);
|
||||
CHECK(strstr(diagnostic, "migrate database to schema v32") != NULL);
|
||||
CHECK(strstr(diagnostic, "migrate database to schema v33") != NULL);
|
||||
CHECK(strstr(diagnostic, "performed_sets_v9") != NULL);
|
||||
|
||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||
|
|
|
|||
Loading…
Reference in a new issue