feat(cardio): guide phases from measured heart rate

This commit is contained in:
fy59 2026-09-23 14:42:59 +02:00
parent 8371d5e16c
commit ad493a72aa
80 changed files with 3385 additions and 105 deletions

View file

@ -48,6 +48,19 @@
- 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.
- Added fresh-signal BPM-guided cardio phases. Android schema v31 stores
ordered phase runs against the active cardio occurrence, immutable resolved
BPM targets, optional exact calibration provenance, exit conditions and
instruction changes. The engine uses a 2 BPM hysteresis band and a 3-second
confirmation interval to avoid boundary oscillation; stale signal suspends
guidance immediately and never reuses an old BPM. Android exposes live
Accélère / Maintiens / Ralentis guidance during the active cardio exercise.
Completed runs synchronize once through `trainlog-cardio-guidance` V1 into
desktop schema v34. Desktop verifies cardio-session ownership, exercise
timeline, calibration provenance and exact raw HR evidence for instruction
events carrying BPM before accepting them.
Development opened after stable v0.1.4 release.
## 0.1.4 — 2026-09-22

View file

@ -36,7 +36,7 @@ Programs. Sessions → Programmes remains the technical administration/import,
list, detail, archive, and delete surface.
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, dedicated synchronized `cardio` sessions, and a versioned measured Cardio calibration; BPM-guided cardio phases are the current implementation cursor.
0.1.5 development now has validated persistent CYCPLUS acquisition, the global `♥ BPM` indicator, training exercise timestamps, one-tap Android Sleep capture, dedicated synchronized `cardio` sessions, versioned measured Cardio calibration and fresh-signal BPM-guided phases; factual Web cardio/timeline visualization is the current implementation cursor.
## Releases

View file

@ -225,7 +225,7 @@ internal class AndroidBackupService(
manifest.getInt("version") == VERSION &&
manifest.getString("package") == context.packageName &&
manifest.getBoolean("plaintext") &&
manifest.getInt("schema") in 17..30
manifest.getInt("schema") in 17..31
) {
"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..30) { "Unsupported Android schema ${db.version}." }
require(db.version in 17..31) { "Unsupported Android schema ${db.version}." }
return db.version
} finally {
db.close()

View file

@ -0,0 +1,190 @@
package com.labfytools.trainlog.data
import com.labfytools.trainlog.model.CardioGuidanceInstruction
import com.labfytools.trainlog.model.CardioGuidanceOutput
import com.labfytools.trainlog.model.CardioTargetDefinition
import com.labfytools.trainlog.model.CardioTargetSnapshot
import kotlin.math.roundToInt
internal object CardioTargetResolver {
fun resolve(definition: CardioTargetDefinition): CardioTargetSnapshot? =
when (definition) {
is CardioTargetDefinition.AbsoluteBpm -> {
if (!validBounds(definition.minimumBpm, definition.maximumBpm)) return null
CardioTargetSnapshot(
minimumBpm = definition.minimumBpm,
maximumBpm = definition.maximumBpm,
)
}
is CardioTargetDefinition.CalibrationPercent -> {
if (
definition.calibrationId.isBlank() ||
definition.observedPeakBpm !in 1..65535 ||
definition.minimumPercent !in 1..100 ||
definition.maximumPercent !in 1..100 ||
definition.minimumPercent > definition.maximumPercent
) return null
val minimum =
(definition.observedPeakBpm * definition.minimumPercent / 100.0)
.roundToInt()
val maximum =
(definition.observedPeakBpm * definition.maximumPercent / 100.0)
.roundToInt()
if (!validBounds(minimum, maximum)) return null
CardioTargetSnapshot(
minimumBpm = minimum,
maximumBpm = maximum,
calibrationId = definition.calibrationId,
calibrationObservedPeakBpm = definition.observedPeakBpm,
minimumPercent = definition.minimumPercent,
maximumPercent = definition.maximumPercent,
)
}
}
private fun validBounds(minimum: Int, maximum: Int): Boolean =
minimum in 1..65535 && maximum in 1..65535 && minimum <= maximum
}
/**
* Pure state machine for BPM guidance.
*
* Hysteresis and confirmation time are UI/control stability parameters, not
* physiological zones. The engine never extrapolates a stale measurement.
*/
internal object CardioPhaseCompletion {
fun isComplete(
phase: com.labfytools.trainlog.model.CardioGuidedPhase,
elapsedSeconds: Int,
bpm: Int?,
fresh: Boolean,
): Boolean {
if (elapsedSeconds < 0) return false
return when (val exit = phase.exitCondition) {
is com.labfytools.trainlog.model.CardioPhaseExitCondition.FixedDuration ->
elapsedSeconds >= exit.seconds
com.labfytools.trainlog.model.CardioPhaseExitCondition.EnterTarget -> {
val target = phase.target ?: return false
fresh && bpm != null && bpm in target.minimumBpm..target.maximumBpm
}
is com.labfytools.trainlog.model.CardioPhaseExitCondition.RecoverBelow ->
fresh && bpm != null && bpm <= exit.bpm
is com.labfytools.trainlog.model.CardioPhaseExitCondition.DurationOrRecoverBelow ->
elapsedSeconds >= exit.maximumSeconds ||
(fresh && bpm != null && bpm <= exit.bpm)
}
}
}
internal class CardioGuidanceEngine(
private val hysteresisBpm: Int = DEFAULT_HYSTERESIS_BPM,
private val confirmationMs: Long = DEFAULT_CONFIRMATION_MS,
initialInstruction: CardioGuidanceInstruction = CardioGuidanceInstruction.MAINTAIN,
) {
private var current =
initialInstruction.takeUnless { it == CardioGuidanceInstruction.SUSPENDED }
?: CardioGuidanceInstruction.MAINTAIN
private var candidate: CardioGuidanceInstruction? = null
private var candidateSinceMs: Long? = null
init {
require(hysteresisBpm in 0..20)
require(confirmationMs in 0L..30_000L)
}
fun reset() {
current = CardioGuidanceInstruction.MAINTAIN
candidate = null
candidateSinceMs = null
}
fun evaluate(
target: CardioTargetSnapshot?,
bpm: Int?,
fresh: Boolean,
nowMs: Long,
): CardioGuidanceOutput {
require(nowMs >= 0L)
if (!fresh || bpm == null || bpm !in 1..65535 || target == null) {
candidate = null
candidateSinceMs = null
return CardioGuidanceOutput(
instruction = CardioGuidanceInstruction.SUSPENDED,
bpm = null,
target = target,
)
}
require(target.minimumBpm in 1..65535)
require(target.maximumBpm in target.minimumBpm..65535)
val desired = desiredInstruction(target, bpm)
if (desired == current) {
candidate = null
candidateSinceMs = null
return CardioGuidanceOutput(current, bpm, target)
}
if (candidate != desired) {
candidate = desired
candidateSinceMs = nowMs
}
val since = checkNotNull(candidateSinceMs)
if (nowMs - since >= confirmationMs) {
current = desired
candidate = null
candidateSinceMs = null
}
return CardioGuidanceOutput(
instruction = current,
bpm = bpm,
target = target,
pendingInstruction = candidate,
)
}
private fun desiredInstruction(
target: CardioTargetSnapshot,
bpm: Int,
): CardioGuidanceInstruction {
// A target entered from either side immediately trends toward MAINTAIN.
if (bpm in target.minimumBpm..target.maximumBpm) {
return CardioGuidanceInstruction.MAINTAIN
}
return when (current) {
CardioGuidanceInstruction.ACCELERATE ->
if (bpm >= target.minimumBpm + hysteresisBpm) {
CardioGuidanceInstruction.MAINTAIN
} else {
CardioGuidanceInstruction.ACCELERATE
}
CardioGuidanceInstruction.SLOW_DOWN ->
if (bpm <= target.maximumBpm - hysteresisBpm) {
CardioGuidanceInstruction.MAINTAIN
} else {
CardioGuidanceInstruction.SLOW_DOWN
}
CardioGuidanceInstruction.MAINTAIN,
CardioGuidanceInstruction.SUSPENDED ->
when {
bpm < target.minimumBpm - hysteresisBpm ->
CardioGuidanceInstruction.ACCELERATE
bpm > target.maximumBpm + hysteresisBpm ->
CardioGuidanceInstruction.SLOW_DOWN
else -> CardioGuidanceInstruction.MAINTAIN
}
}
}
companion object {
const val DEFAULT_HYSTERESIS_BPM = 2
const val DEFAULT_CONFIRMATION_MS = 3_000L
}
}

View file

@ -0,0 +1,40 @@
package com.labfytools.trainlog.data
import com.labfytools.trainlog.model.CardioGuidanceInstruction
import com.labfytools.trainlog.model.CardioPhaseKind
import com.labfytools.trainlog.model.CardioTargetSnapshot
import java.util.concurrent.CopyOnWriteArraySet
internal data class CardioGuidanceLiveSnapshot(
val phaseId: String? = null,
val phaseKind: CardioPhaseKind? = null,
val instruction: CardioGuidanceInstruction = CardioGuidanceInstruction.SUSPENDED,
val bpm: Int? = null,
val target: CardioTargetSnapshot? = null,
val startedAt: String? = null,
)
internal object CardioGuidanceLiveState {
private val listeners =
CopyOnWriteArraySet<(CardioGuidanceLiveSnapshot) -> Unit>()
@Volatile
private var snapshot = CardioGuidanceLiveSnapshot()
fun current(): CardioGuidanceLiveSnapshot = snapshot
fun publish(next: CardioGuidanceLiveSnapshot) {
snapshot = next
listeners.forEach { it(next) }
}
fun clear() {
publish(CardioGuidanceLiveSnapshot())
}
fun subscribe(listener: (CardioGuidanceLiveSnapshot) -> Unit): AutoCloseable {
listeners += listener
listener(snapshot)
return AutoCloseable { listeners -= listener }
}
}

View file

@ -23,6 +23,8 @@ import android.os.SystemClock
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import com.labfytools.trainlog.R
import com.labfytools.trainlog.model.CardioGuidanceInstruction
import java.time.Duration
import java.time.OffsetDateTime
class HeartRateSensorService : Service() {
@ -38,6 +40,8 @@ class HeartRateSensorService : Service() {
private var lastMeasurementElapsed = 0L
private var lastNotificationBpm: Int? = null
private var repository: TrainlogRepository? = null
private var guidancePhaseId: String? = null
private var guidanceEngine: CardioGuidanceEngine? = null
private fun repository(): TrainlogRepository =
repository ?: TrainlogRepository(applicationContext).also { repository = it }
@ -61,6 +65,7 @@ class HeartRateSensorService : Service() {
HeartRateLiveState.current().phase == HeartRateLivePhase.CONNECTED
) {
HeartRateLiveState.stale()
suspendCardioGuidance()
updateForegroundNotification()
}
if (age >= STALE_RECONNECT_AFTER_MS && gatt != null) {
@ -279,21 +284,144 @@ class HeartRateSensorService : Service() {
HeartRateLiveState.measurement(parsed.measurement)
val observedAt = OffsetDateTime.now().toString()
val repository = repository()
repository.recordLiveHeartRateForActiveSession(
sensorName = selected?.name,
observedAt = observedAt,
measurement = parsed.measurement,
) ?: repository.recordLiveHeartRateForActiveSleep(
sensorName = selected?.name,
observedAt = observedAt,
measurement = parsed.measurement,
)
val sessionRecorded =
repository.recordLiveHeartRateForActiveSession(
sensorName = selected?.name,
observedAt = observedAt,
measurement = parsed.measurement,
)
if (sessionRecorded != null) {
processCardioGuidance(repository, observedAt, parsed.measurement.bpm)
} else {
repository.recordLiveHeartRateForActiveSleep(
sensorName = selected?.name,
observedAt = observedAt,
measurement = parsed.measurement,
)
clearCardioGuidanceEngine()
}
if (lastNotificationBpm != parsed.measurement.bpm) {
lastNotificationBpm = parsed.measurement.bpm
updateForegroundNotification()
}
}
private fun processCardioGuidance(
repository: TrainlogRepository,
observedAt: String,
bpm: Int,
) {
val phase = repository.activeCardioGuidancePhase()
if (phase == null) {
clearCardioGuidanceEngine()
return
}
if (guidancePhaseId != phase.phase.phaseId || guidanceEngine == null) {
guidancePhaseId = phase.phase.phaseId
guidanceEngine =
CardioGuidanceEngine(initialInstruction = phase.currentInstruction)
}
val engine = checkNotNull(guidanceEngine)
val output =
engine.evaluate(
target = phase.phase.target,
bpm = bpm,
fresh = true,
nowMs = SystemClock.elapsedRealtime(),
)
repository.recordCardioGuidanceOutput(
phase.phase.phaseId,
observedAt,
output,
)
CardioGuidanceLiveState.publish(
CardioGuidanceLiveSnapshot(
phaseId = phase.phase.phaseId,
phaseKind = phase.phase.kind,
instruction = output.instruction,
bpm = output.bpm,
target = phase.phase.target,
startedAt = phase.startedAt,
),
)
maybeFinishCardioGuidancePhase(repository, phase, observedAt, bpm, fresh = true)
}
private fun suspendCardioGuidance() {
val repository = repository ?: return
val phase = repository.activeCardioGuidancePhase() ?: run {
clearCardioGuidanceEngine()
return
}
if (guidancePhaseId != phase.phase.phaseId || guidanceEngine == null) {
guidancePhaseId = phase.phase.phaseId
guidanceEngine =
CardioGuidanceEngine(initialInstruction = phase.currentInstruction)
}
val output =
checkNotNull(guidanceEngine).evaluate(
target = phase.phase.target,
bpm = null,
fresh = false,
nowMs = SystemClock.elapsedRealtime(),
)
val observedAt = OffsetDateTime.now().toString()
repository.recordCardioGuidanceOutput(
phase.phase.phaseId,
observedAt,
output,
)
CardioGuidanceLiveState.publish(
CardioGuidanceLiveSnapshot(
phaseId = phase.phase.phaseId,
phaseKind = phase.phase.kind,
instruction = CardioGuidanceInstruction.SUSPENDED,
bpm = null,
target = phase.phase.target,
startedAt = phase.startedAt,
),
)
maybeFinishCardioGuidancePhase(repository, phase, observedAt, null, fresh = false)
}
private fun maybeFinishCardioGuidancePhase(
repository: TrainlogRepository,
phase: com.labfytools.trainlog.model.CardioGuidancePhaseRecord,
observedAt: String,
bpm: Int?,
fresh: Boolean,
) {
val elapsedSeconds =
runCatching {
Duration.between(
OffsetDateTime.parse(phase.startedAt),
OffsetDateTime.parse(observedAt),
).seconds
}
.getOrNull()
?.coerceIn(0L, Int.MAX_VALUE.toLong())
?.toInt()
?: return
if (
CardioPhaseCompletion.isComplete(
phase.phase,
elapsedSeconds,
bpm,
fresh,
)
) {
repository.finishCardioGuidancePhase(phase.phase.phaseId, observedAt)
clearCardioGuidanceEngine()
}
}
private fun clearCardioGuidanceEngine() {
guidancePhaseId = null
guidanceEngine = null
CardioGuidanceLiveState.clear()
}
private fun failConnection() {
main.removeCallbacks(connectionTimeoutRunnable)
closeGatt()

View file

@ -74,6 +74,8 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
"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),
"cardio-guidance" to
Kind("trainlog-cardio-guidance", 1, "cardio-guidance-v1.json", false),
"session-timeline" to
Kind("trainlog-session-timeline", 1, "session-timeline-v1.json", false),
)
@ -448,6 +450,17 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
),
)
}
val guidance = JSONObject(checkNotNull(artifacts["cardio-guidance"]))
val guidanceRuns = guidance.getJSONArray("runs")
for (index in 0 until guidanceRuns.length()) {
db.execSQL(
"INSERT INTO cardio_guidance_generation(generation_id,run_id) VALUES(?,?)",
arrayOf(
generationId,
guidanceRuns.getJSONObject(index).getString("run_id"),
),
)
}
val timeline = JSONObject(checkNotNull(artifacts["session-timeline"]))
val timelineSessions = timeline.getJSONArray("sessions")
for (index in 0 until timelineSessions.length()) {
@ -1113,6 +1126,14 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
"m.calibration_id=cardio_calibrations.calibration_id)",
arrayOf(ack.getString("consumed_at"), ack.getString("generation_id")),
)
db.execSQL(
"UPDATE cardio_guidance_runs SET acknowledged_at=? WHERE " +
"acknowledged_at IS NULL AND EXISTS(" +
"SELECT 1 FROM cardio_guidance_generation m " +
"WHERE m.generation_id=? AND " +
"m.run_id=cardio_guidance_runs.run_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(" +

View file

@ -20,6 +20,15 @@ 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.CardioGuidanceEvent
import com.labfytools.trainlog.model.CardioGuidanceInstruction
import com.labfytools.trainlog.model.CardioGuidanceOutput
import com.labfytools.trainlog.model.CardioGuidancePhaseRecord
import com.labfytools.trainlog.model.CardioGuidanceRun
import com.labfytools.trainlog.model.CardioGuidedPhase
import com.labfytools.trainlog.model.CardioPhaseExitCondition
import com.labfytools.trainlog.model.CardioPhaseKind
import com.labfytools.trainlog.model.CardioTargetSnapshot
import com.labfytools.trainlog.model.BodyObservationSummary
import com.labfytools.trainlog.model.ExerciseDataFields
import com.labfytools.trainlog.model.ExerciseProfile
@ -541,6 +550,15 @@ class TrainlogRepository(
data class DatabaseError(val message: String) : CardioCalibrationResult
}
sealed interface CardioGuidanceMutationResult {
data class Applied(val phase: CardioGuidancePhaseRecord? = null) :
CardioGuidanceMutationResult
data class Invalid(val message: String) : CardioGuidanceMutationResult
data class ActiveConflict(val phaseId: String) : CardioGuidanceMutationResult
data object NotFound : CardioGuidanceMutationResult
data class DatabaseError(val message: String) : CardioGuidanceMutationResult
}
private fun legacySessionTypeWire(type: SessionType): String =
if (type == SessionType.MAX_TEST) SessionType.MAX_TEST.wireValue else SessionType.TRAINING.wireValue
@ -1673,6 +1691,12 @@ class TrainlogRepository(
"WHERE active_slot=1 AND context_id=? AND active_exercise_entry_id=?",
arrayOf(row.first, entryId),
)
db.execSQL(
"UPDATE cardio_guidance_phases SET ended_at=?,active_slot=NULL " +
"WHERE entry_id=? AND active_slot=1 AND run_id IN(" +
"SELECT run_id FROM cardio_guidance_runs WHERE session_id=?)",
arrayOf(endedAt, entryId, row.first),
)
val timing =
SessionExerciseTiming(row.first, entryId, row.second, row.third, endedAt)
db.setTransactionSuccessful()
@ -1820,6 +1844,443 @@ class TrainlogRepository(
.toString()
}
private fun guidanceInstructionWire(value: CardioGuidanceInstruction): String =
when (value) {
CardioGuidanceInstruction.ACCELERATE -> "accelerate"
CardioGuidanceInstruction.MAINTAIN -> "maintain"
CardioGuidanceInstruction.SLOW_DOWN -> "slow_down"
CardioGuidanceInstruction.SUSPENDED -> "suspended"
}
private fun guidanceInstructionFromWire(value: String): CardioGuidanceInstruction =
when (value) {
"accelerate" -> CardioGuidanceInstruction.ACCELERATE
"maintain" -> CardioGuidanceInstruction.MAINTAIN
"slow_down" -> CardioGuidanceInstruction.SLOW_DOWN
"suspended" -> CardioGuidanceInstruction.SUSPENDED
else -> error("Unknown cardio guidance instruction: $value")
}
private fun readCardioGuidancePhase(
db: SQLiteDatabase,
selection: String,
args: Array<String>,
): CardioGuidancePhaseRecord? =
db.rawQuery(
"SELECT r.run_id,r.session_id,p.entry_id,p.phase_id,p.position,p.kind," +
"p.target_min_bpm,p.target_max_bpm,p.calibration_id," +
"p.calibration_observed_peak_bpm,p.minimum_percent,p.maximum_percent," +
"p.exit_kind,p.exit_seconds,p.exit_bpm,p.started_at,p.ended_at," +
"p.current_instruction FROM cardio_guidance_phases p " +
"JOIN cardio_guidance_runs r ON r.run_id=p.run_id WHERE $selection LIMIT 1",
args,
).use { c ->
if (!c.moveToFirst()) return null
val target =
if (c.isNull(6)) {
null
} else {
CardioTargetSnapshot(
minimumBpm = c.getInt(6),
maximumBpm = c.getInt(7),
calibrationId = if (c.isNull(8)) null else c.getString(8),
calibrationObservedPeakBpm = if (c.isNull(9)) null else c.getInt(9),
minimumPercent = if (c.isNull(10)) null else c.getInt(10),
maximumPercent = if (c.isNull(11)) null else c.getInt(11),
)
}
val exit =
when (c.getString(12)) {
"fixed_duration" -> CardioPhaseExitCondition.FixedDuration(c.getInt(13))
"enter_target" -> CardioPhaseExitCondition.EnterTarget
"recover_below" -> CardioPhaseExitCondition.RecoverBelow(c.getInt(14))
"duration_or_recover" ->
CardioPhaseExitCondition.DurationOrRecoverBelow(
c.getInt(13),
c.getInt(14),
)
else -> error("Unknown cardio guidance exit condition.")
}
CardioGuidancePhaseRecord(
runId = c.getString(0),
sessionId = c.getString(1),
entryId = c.getString(2),
phase =
CardioGuidedPhase(
phaseId = c.getString(3),
kind =
CardioPhaseKind.entries.first {
it.wireValue == c.getString(5)
},
target = target,
exitCondition = exit,
),
position = c.getInt(4),
startedAt = c.getString(15),
endedAt = if (c.isNull(16)) null else c.getString(16),
currentInstruction = guidanceInstructionFromWire(c.getString(17)),
)
}
fun activeCardioGuidancePhase(): CardioGuidancePhaseRecord? =
readCardioGuidancePhase(
database.readableDatabase,
"p.active_slot=1",
emptyArray(),
)
fun listCardioGuidanceEvents(phaseId: String): List<CardioGuidanceEvent> {
if (!sleepIdentity(phaseId, "cgp")) return emptyList()
val phase =
readCardioGuidancePhase(
database.readableDatabase,
"p.phase_id=?",
arrayOf(phaseId),
) ?: return emptyList()
val output = mutableListOf<CardioGuidanceEvent>()
database.readableDatabase.rawQuery(
"SELECT sequence,observed_at,instruction,bpm,target_min_bpm,target_max_bpm " +
"FROM cardio_guidance_events WHERE run_id=? AND phase_id=? ORDER BY sequence",
arrayOf(phase.runId, phaseId),
).use { c ->
while (c.moveToNext()) {
output +=
CardioGuidanceEvent(
sequence = c.getLong(0),
observedAt = c.getString(1),
instruction = guidanceInstructionFromWire(c.getString(2)),
bpm = if (c.isNull(3)) null else c.getInt(3),
targetMinimumBpm = if (c.isNull(4)) null else c.getInt(4),
targetMaximumBpm = if (c.isNull(5)) null else c.getInt(5),
)
}
}
return output
}
private fun validateGuidanceTarget(
db: SQLiteDatabase,
target: CardioTargetSnapshot?,
): String? {
if (target == null) return null
if (
target.minimumBpm !in 1..65535 ||
target.maximumBpm !in target.minimumBpm..65535
) return "Invalid cardio guidance target."
val hasCalibration = target.calibrationId != null
val provenanceComplete =
target.calibrationObservedPeakBpm != null &&
target.minimumPercent != null &&
target.maximumPercent != null
if (hasCalibration != provenanceComplete) {
return "Incomplete cardio calibration target provenance."
}
if (!hasCalibration) {
if (
target.calibrationObservedPeakBpm != null ||
target.minimumPercent != null ||
target.maximumPercent != null
) return "Unexpected cardio target provenance."
return null
}
if (
!sleepIdentity(checkNotNull(target.calibrationId), "cal") ||
target.calibrationObservedPeakBpm !in 1..65535 ||
target.minimumPercent !in 1..100 ||
target.maximumPercent !in checkNotNull(target.minimumPercent)..100
) return "Invalid cardio calibration target provenance."
val exact =
db.rawQuery(
"SELECT 1 FROM cardio_calibrations WHERE calibration_id=? " +
"AND phase='completed' AND observed_peak_bpm=? LIMIT 1",
arrayOf(
target.calibrationId,
target.calibrationObservedPeakBpm.toString(),
),
).use { it.moveToFirst() }
if (!exact) return "Cardio calibration reference is unavailable."
return null
}
private fun validateGuidedPhase(
db: SQLiteDatabase,
phase: CardioGuidedPhase,
): String? {
if (!sleepIdentity(phase.phaseId, "cgp")) return "Invalid cardio phase identity."
validateGuidanceTarget(db, phase.target)?.let { return it }
return when (val exit = phase.exitCondition) {
is CardioPhaseExitCondition.FixedDuration ->
if (exit.seconds in 1..86400) null else "Invalid fixed cardio phase duration."
CardioPhaseExitCondition.EnterTarget ->
if (phase.target != null) null else "Target-entry phase requires a BPM target."
is CardioPhaseExitCondition.RecoverBelow ->
if (exit.bpm in 1..65535) null else "Invalid recovery threshold."
is CardioPhaseExitCondition.DurationOrRecoverBelow ->
if (exit.maximumSeconds in 1..86400 && exit.bpm in 1..65535) null
else "Invalid bounded recovery condition."
}
}
fun startCardioGuidancePhase(
entryId: String,
phase: CardioGuidedPhase,
startedAt: String = OffsetDateTime.now().toString(),
): CardioGuidanceMutationResult {
if (
!SESSION_ENTRY_ID_V4_PATTERN.matches(entryId) ||
TrainlogTimestamp.parse(startedAt) == null
) return CardioGuidanceMutationResult.Invalid("Invalid cardio phase start.")
val context = activeSessionTimelineContext()
?: return CardioGuidanceMutationResult.Invalid("No active cardio session.")
if (context.sessionType != SessionType.CARDIO || context.activeEntryId != entryId) {
return CardioGuidanceMutationResult.Invalid(
"Cardio guidance requires the active cardio exercise.",
)
}
val db = database.writableDatabase
db.beginTransaction()
return try {
val exerciseStartedAt =
db.rawQuery(
"SELECT started_at FROM session_timeline_exercises " +
"WHERE session_id=? AND entry_id=? AND active_slot=1 LIMIT 1",
arrayOf(context.sessionId, entryId),
).use { cursor ->
if (!cursor.moveToFirst()) null else cursor.getString(0)
} ?: return CardioGuidanceMutationResult.Invalid(
"Active cardio exercise timing is unavailable.",
)
if (
TrainlogTimestamp.parse(startedAt)!! <
TrainlogTimestamp.parse(exerciseStartedAt)!!
) {
return CardioGuidanceMutationResult.Invalid(
"Cardio phase cannot start before its exercise.",
)
}
validateGuidedPhase(db, phase)?.let {
return CardioGuidanceMutationResult.Invalid(it)
}
val conflict =
db.rawQuery(
"SELECT phase_id FROM cardio_guidance_phases WHERE active_slot=1 LIMIT 1",
null,
).use { c -> if (c.moveToFirst()) c.getString(0) else null }
if (conflict != null) return CardioGuidanceMutationResult.ActiveConflict(conflict)
val existingRun =
db.rawQuery(
"SELECT run_id,ended_at FROM cardio_guidance_runs WHERE session_id=?",
arrayOf(context.sessionId),
).use { c ->
if (!c.moveToFirst()) null
else c.getString(0) to if (c.isNull(1)) null else c.getString(1)
}
if (existingRun?.second != null) {
return CardioGuidanceMutationResult.Invalid("Cardio guidance run is already closed.")
}
val runId =
existingRun?.first ?: sleepId("cgr").also {
db.execSQL(
"INSERT INTO cardio_guidance_runs(" +
"run_id,session_id,started_at,ended_at,acknowledged_at) " +
"VALUES(?,?,?,NULL,NULL)",
arrayOf(it, context.sessionId, startedAt),
)
}
val position =
db.rawQuery(
"SELECT COALESCE(MAX(position),-1)+1 FROM cardio_guidance_phases WHERE run_id=?",
arrayOf(runId),
).use { c -> check(c.moveToFirst()); c.getInt(0) }
val target = phase.target
val exit = phase.exitCondition
val exitKind: String
var exitSeconds: Int? = null
var exitBpm: Int? = null
when (exit) {
is CardioPhaseExitCondition.FixedDuration -> {
exitKind = "fixed_duration"
exitSeconds = exit.seconds
}
CardioPhaseExitCondition.EnterTarget -> exitKind = "enter_target"
is CardioPhaseExitCondition.RecoverBelow -> {
exitKind = "recover_below"
exitBpm = exit.bpm
}
is CardioPhaseExitCondition.DurationOrRecoverBelow -> {
exitKind = "duration_or_recover"
exitSeconds = exit.maximumSeconds
exitBpm = exit.bpm
}
}
db.execSQL(
"INSERT INTO cardio_guidance_phases(" +
"run_id,phase_id,entry_id,position,kind,target_min_bpm,target_max_bpm," +
"calibration_id,calibration_observed_peak_bpm,minimum_percent,maximum_percent," +
"exit_kind,exit_seconds,exit_bpm,started_at,ended_at,active_slot,current_instruction) " +
"VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,NULL,1,'maintain')",
arrayOf<Any?>(
runId,
phase.phaseId,
entryId,
position,
phase.kind.wireValue,
target?.minimumBpm,
target?.maximumBpm,
target?.calibrationId,
target?.calibrationObservedPeakBpm,
target?.minimumPercent,
target?.maximumPercent,
exitKind,
exitSeconds,
exitBpm,
startedAt,
),
)
val stored = checkNotNull(
readCardioGuidancePhase(db, "p.phase_id=?", arrayOf(phase.phaseId)),
)
db.setTransactionSuccessful()
CardioGuidanceMutationResult.Applied(stored)
} catch (error: SQLiteConstraintException) {
CardioGuidanceMutationResult.Invalid(error.message ?: "Cardio phase constraint failed.")
} catch (error: Exception) {
CardioGuidanceMutationResult.DatabaseError(error.message ?: "Cardio phase start failed.")
} finally {
db.endTransaction()
}
}
fun recordCardioGuidanceOutput(
phaseId: String,
observedAt: String,
output: CardioGuidanceOutput,
): CardioGuidanceMutationResult {
if (!sleepIdentity(phaseId, "cgp") || TrainlogTimestamp.parse(observedAt) == null) {
return CardioGuidanceMutationResult.Invalid("Invalid cardio guidance event.")
}
val db = database.writableDatabase
db.beginTransaction()
return try {
val phase =
readCardioGuidancePhase(db, "p.phase_id=? AND p.active_slot=1", arrayOf(phaseId))
?: return CardioGuidanceMutationResult.NotFound
if (output.target != phase.phase.target) {
return CardioGuidanceMutationResult.Invalid("Cardio guidance target changed during phase.")
}
if (
(output.instruction == CardioGuidanceInstruction.SUSPENDED && output.bpm != null) ||
(output.instruction != CardioGuidanceInstruction.SUSPENDED &&
(output.bpm == null || output.bpm !in 1..65535))
) return CardioGuidanceMutationResult.Invalid("Invalid cardio guidance measurement.")
val current = phase.currentInstruction
if (current == output.instruction) {
db.setTransactionSuccessful()
return CardioGuidanceMutationResult.Applied(phase)
}
val sequence =
db.rawQuery(
"SELECT COALESCE(MAX(sequence),-1)+1 FROM cardio_guidance_events " +
"WHERE run_id=? AND phase_id=?",
arrayOf(phase.runId, phaseId),
).use { c -> check(c.moveToFirst()); c.getLong(0) }
db.execSQL(
"INSERT INTO cardio_guidance_events(" +
"run_id,phase_id,sequence,observed_at,instruction,bpm,target_min_bpm,target_max_bpm) " +
"VALUES(?,?,?,?,?,?,?,?)",
arrayOf<Any?>(
phase.runId,
phaseId,
sequence,
observedAt,
guidanceInstructionWire(output.instruction),
output.bpm,
output.target?.minimumBpm,
output.target?.maximumBpm,
),
)
db.execSQL(
"UPDATE cardio_guidance_phases SET current_instruction=? WHERE phase_id=?",
arrayOf(guidanceInstructionWire(output.instruction), phaseId),
)
val updated = checkNotNull(
readCardioGuidancePhase(db, "p.phase_id=?", arrayOf(phaseId)),
)
db.setTransactionSuccessful()
CardioGuidanceMutationResult.Applied(updated)
} catch (error: Exception) {
CardioGuidanceMutationResult.DatabaseError(
error.message ?: "Cardio guidance event failed.",
)
} finally {
db.endTransaction()
}
}
fun finishCardioGuidancePhase(
phaseId: String,
endedAt: String = OffsetDateTime.now().toString(),
): CardioGuidanceMutationResult {
if (!sleepIdentity(phaseId, "cgp") || TrainlogTimestamp.parse(endedAt) == null) {
return CardioGuidanceMutationResult.Invalid("Invalid cardio phase end.")
}
val db = database.writableDatabase
db.beginTransaction()
return try {
val phase =
readCardioGuidancePhase(db, "p.phase_id=? AND p.active_slot=1", arrayOf(phaseId))
?: return CardioGuidanceMutationResult.NotFound
check(TrainlogTimestamp.parse(endedAt)!! >= TrainlogTimestamp.parse(phase.startedAt)!!) {
"Cardio phase cannot end before it starts."
}
val values = ContentValues().apply {
put("ended_at", endedAt)
putNull("active_slot")
}
check(
db.update(
"cardio_guidance_phases",
values,
"phase_id=? AND active_slot=1",
arrayOf(phaseId),
) == 1,
)
val updated = checkNotNull(
readCardioGuidancePhase(db, "p.phase_id=?", arrayOf(phaseId)),
)
db.setTransactionSuccessful()
CardioGuidanceMutationResult.Applied(updated)
} catch (error: IllegalStateException) {
CardioGuidanceMutationResult.Invalid(error.message ?: "Invalid cardio phase end.")
} catch (error: Exception) {
CardioGuidanceMutationResult.DatabaseError(error.message ?: "Cardio phase end failed.")
} finally {
db.endTransaction()
}
}
private fun closeCardioGuidanceRunForSession(
db: SQLiteDatabase,
sessionId: String,
endedAt: String,
) {
val active =
db.rawQuery(
"SELECT 1 FROM cardio_guidance_phases p JOIN cardio_guidance_runs r " +
"ON r.run_id=p.run_id WHERE r.session_id=? AND p.active_slot=1 LIMIT 1",
arrayOf(sessionId),
).use { it.moveToFirst() }
check(!active) { "Cardio guidance phase is still active." }
db.execSQL(
"UPDATE cardio_guidance_runs SET ended_at=? WHERE session_id=? AND ended_at IS NULL",
arrayOf(endedAt, sessionId),
)
}
fun startCardioCalibration(
startedAt: String = OffsetDateTime.now().toString(),
): CardioCalibrationResult {
@ -2573,6 +3034,145 @@ class TrainlogRepository(
.toString()
}
internal fun buildCardioGuidanceV1Json(): String {
val db = database.readableDatabase
val runs = JSONArray()
db.rawQuery(
"SELECT r.run_id,r.session_id,r.started_at,r.ended_at FROM cardio_guidance_runs r " +
"JOIN sessions s ON s.session_id=r.session_id AND s.session_kind='cardio' " +
"WHERE r.ended_at IS NOT NULL AND r.acknowledged_at IS NULL AND NOT EXISTS(" +
"SELECT 1 FROM cardio_guidance_generation m " +
"JOIN sync_generations g ON g.generation_id=m.generation_id " +
"WHERE m.run_id=r.run_id AND " +
"g.status IN('captured','published','waiting_acknowledgement')) " +
"ORDER BY r.ended_at,r.run_id LIMIT ?",
arrayOf(MAX_CARDIO_GUIDANCE_RUNS_PER_GENERATION.toString()),
).use { runCursor ->
while (runCursor.moveToNext()) {
val runId = runCursor.getString(0)
val phases = JSONArray()
db.rawQuery(
"SELECT phase_id,entry_id,position,kind,target_min_bpm,target_max_bpm," +
"calibration_id,calibration_observed_peak_bpm,minimum_percent,maximum_percent," +
"exit_kind,exit_seconds,exit_bpm,started_at,ended_at,current_instruction " +
"FROM cardio_guidance_phases WHERE run_id=? ORDER BY position,phase_id",
arrayOf(runId),
).use { phaseCursor ->
var phaseCount = 0
while (phaseCursor.moveToNext()) {
check(++phaseCount <= MAX_CARDIO_GUIDANCE_PHASES_PER_RUN)
check(!phaseCursor.isNull(14)) {
"Completed cardio guidance run contains an active phase."
}
val target =
if (phaseCursor.isNull(4)) {
JSONObject.NULL
} else {
JSONObject()
.put("minimum_bpm", phaseCursor.getInt(4))
.put("maximum_bpm", phaseCursor.getInt(5))
.put(
"calibration_id",
if (phaseCursor.isNull(6)) JSONObject.NULL
else phaseCursor.getString(6),
)
.put(
"calibration_observed_peak_bpm",
if (phaseCursor.isNull(7)) JSONObject.NULL
else phaseCursor.getInt(7),
)
.put(
"minimum_percent",
if (phaseCursor.isNull(8)) JSONObject.NULL
else phaseCursor.getInt(8),
)
.put(
"maximum_percent",
if (phaseCursor.isNull(9)) JSONObject.NULL
else phaseCursor.getInt(9),
)
}
val exit =
JSONObject()
.put("kind", phaseCursor.getString(10))
.put(
"seconds",
if (phaseCursor.isNull(11)) JSONObject.NULL
else phaseCursor.getInt(11),
)
.put(
"bpm",
if (phaseCursor.isNull(12)) JSONObject.NULL
else phaseCursor.getInt(12),
)
val events = JSONArray()
db.rawQuery(
"SELECT sequence,observed_at,instruction,bpm,target_min_bpm,target_max_bpm " +
"FROM cardio_guidance_events WHERE run_id=? AND phase_id=? ORDER BY sequence",
arrayOf(runId, phaseCursor.getString(0)),
).use { eventCursor ->
var expected = 0L
var count = 0
while (eventCursor.moveToNext()) {
check(++count <= MAX_CARDIO_GUIDANCE_EVENTS_PER_PHASE)
check(eventCursor.getLong(0) == expected++)
events.put(
JSONObject()
.put("sequence", eventCursor.getLong(0))
.put("observed_at", eventCursor.getString(1))
.put("instruction", eventCursor.getString(2))
.put(
"bpm",
if (eventCursor.isNull(3)) JSONObject.NULL
else eventCursor.getInt(3),
)
.put(
"target_minimum_bpm",
if (eventCursor.isNull(4)) JSONObject.NULL
else eventCursor.getInt(4),
)
.put(
"target_maximum_bpm",
if (eventCursor.isNull(5)) JSONObject.NULL
else eventCursor.getInt(5),
),
)
}
}
phases.put(
JSONObject()
.put("phase_id", phaseCursor.getString(0))
.put("entry_id", phaseCursor.getString(1))
.put("position", phaseCursor.getInt(2))
.put("kind", phaseCursor.getString(3))
.put("target", target)
.put("exit_condition", exit)
.put("started_at", phaseCursor.getString(13))
.put("ended_at", phaseCursor.getString(14))
.put("final_instruction", phaseCursor.getString(15))
.put("events", events),
)
}
}
runs.put(
JSONObject()
.put("run_id", runId)
.put("session_id", runCursor.getString(1))
.put("started_at", runCursor.getString(2))
.put("ended_at", runCursor.getString(3))
.put("phases", phases),
)
}
}
return JSONObject()
.put("format", "trainlog-cardio-guidance")
.put("version", 1)
.put("generated_at", OffsetDateTime.now().toString())
.put("runs", runs)
.toString()
}
internal fun buildSessionTimelineV1Json(): String {
val db = database.readableDatabase
val sessions = JSONArray()
@ -2683,6 +3283,7 @@ class TrainlogRepository(
artifacts["sleep-diary"] = buildSleepDiaryV1Json()
artifacts["heart-rate"] = buildHeartRateV1Json()
artifacts["cardio-calibrations"] = buildCardioCalibrationsV1Json()
artifacts["cardio-guidance"] = buildCardioGuidanceV1Json()
artifacts["session-timeline"] = buildSessionTimelineV1Json()
artifacts
}
@ -4719,6 +5320,11 @@ class TrainlogRepository(
arrayOf(ACTIVE_DRAFT_ID.toString()),
)
if (sessionId != null) {
db.delete(
"cardio_guidance_runs",
"session_id=? AND ended_at IS NULL",
arrayOf(sessionId),
)
db.delete(
"heart_rate_captures",
"active_slot=1 AND context_id=? AND context_kind IN('session','cardio')",
@ -4799,6 +5405,24 @@ class TrainlogRepository(
)
}
val finalizedAt = OffsetDateTime.now().toString()
val latestExerciseEnd =
db.rawQuery(
"SELECT MAX(ended_at) FROM session_timeline_exercises WHERE session_id=?",
arrayOf(lifecycleSessionId),
).use { cursor ->
if (!cursor.moveToFirst() || cursor.isNull(0)) null else cursor.getString(0)
}
if (
latestExerciseEnd != null &&
TrainlogTimestamp.parse(latestExerciseEnd)!! >
TrainlogTimestamp.parse(finalizedAt)!!
) {
db.endTransaction()
transactionOpen = false
return FinalizeActiveDraftResult.Invalid(
"Session cannot end before its last exercise marker.",
)
}
val completed =
SessionDraft(
@ -4842,6 +5466,7 @@ class TrainlogRepository(
"AND context_kind IN('session','cardio')",
arrayOf(finalizedAt, sessionId),
)
closeCardioGuidanceRunForSession(db, sessionId, finalizedAt)
/* INVARIANT: completion moves the exact Program provenance from
* the singleton draft to history in the same transaction. */
@ -11165,6 +11790,9 @@ 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 const val MAX_CARDIO_GUIDANCE_RUNS_PER_GENERATION = 128
private const val MAX_CARDIO_GUIDANCE_PHASES_PER_RUN = 128
private const val MAX_CARDIO_GUIDANCE_EVENTS_PER_PHASE = 4096
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
@ -11186,7 +11814,7 @@ private class TrainlogDatabaseHelper(
appContext,
databaseName,
null,
30,
31,
) {
override fun onConfigure(
db: SQLiteDatabase,
@ -11243,6 +11871,7 @@ private class TrainlogDatabaseHelper(
createHeartRateTables(db)
createSessionTimelineTables(db)
createCardioCalibrationTables(db)
createCardioGuidanceTables(db)
ensureCardioCalibrationExercise(db)
seedEquipment(db)
}
@ -11510,6 +12139,14 @@ private class TrainlogDatabaseHelper(
ensureCardioCalibrationExercise(db)
version = 30
}
if (version < 31 && newVersion >= 31) {
/* WHY: guided cardio phases must retain the exact target snapshot
* and instruction changes actually used during the session.
* CONTRACT: v31 is additive and Android-owned. INVARIANT: no BPM,
* calibration, workout or historical phase is reconstructed. */
createCardioGuidanceTables(db)
version = 31
}
if (version != newVersion) {
error(
@ -11700,6 +12337,105 @@ private class TrainlogDatabaseHelper(
)
}
private fun createCardioGuidanceTables(db: SQLiteDatabase) {
db.execSQL(
"""CREATE TABLE IF NOT EXISTS cardio_guidance_runs(
run_id TEXT PRIMARY KEY,
session_id TEXT NOT NULL UNIQUE,
started_at TEXT NOT NULL,
ended_at TEXT,
acknowledged_at TEXT,
CHECK(run_id GLOB 'cgr_*'),
CHECK(session_id GLOB 'se_*')
)""".trimIndent(),
)
db.execSQL(
"""CREATE TABLE IF NOT EXISTS cardio_guidance_phases(
run_id TEXT NOT NULL
REFERENCES cardio_guidance_runs(run_id) ON DELETE CASCADE,
phase_id TEXT NOT NULL UNIQUE,
entry_id TEXT NOT NULL,
position INTEGER NOT NULL CHECK(position>=0),
kind TEXT NOT NULL CHECK(kind IN('warmup','work','recovery','cooldown')),
target_min_bpm INTEGER,
target_max_bpm INTEGER,
calibration_id TEXT,
calibration_observed_peak_bpm INTEGER,
minimum_percent INTEGER,
maximum_percent INTEGER,
exit_kind TEXT NOT NULL
CHECK(exit_kind IN('fixed_duration','enter_target','recover_below','duration_or_recover')),
exit_seconds INTEGER,
exit_bpm INTEGER,
started_at TEXT NOT NULL,
ended_at TEXT,
active_slot INTEGER UNIQUE CHECK(active_slot IS NULL OR active_slot=1),
current_instruction TEXT NOT NULL DEFAULT 'maintain'
CHECK(current_instruction IN('accelerate','maintain','slow_down','suspended')),
PRIMARY KEY(run_id,phase_id),
UNIQUE(run_id,position),
CHECK(entry_id GLOB 'sxe_*'),
CHECK(phase_id GLOB 'cgp_*'),
CHECK(
(target_min_bpm IS NULL AND target_max_bpm IS NULL) OR
(target_min_bpm BETWEEN 1 AND 65535 AND
target_max_bpm BETWEEN target_min_bpm AND 65535)
),
CHECK(
(calibration_id IS NULL AND calibration_observed_peak_bpm IS NULL AND
minimum_percent IS NULL AND maximum_percent IS NULL) OR
(calibration_id GLOB 'cal_*' AND calibration_observed_peak_bpm BETWEEN 1 AND 65535 AND
minimum_percent BETWEEN 1 AND 100 AND maximum_percent BETWEEN minimum_percent AND 100)
),
CHECK(
(exit_kind='fixed_duration' AND exit_seconds>0 AND exit_bpm IS NULL) OR
(exit_kind='enter_target' AND exit_seconds IS NULL AND exit_bpm IS NULL AND target_min_bpm IS NOT NULL) OR
(exit_kind='recover_below' AND exit_seconds IS NULL AND exit_bpm BETWEEN 1 AND 65535) OR
(exit_kind='duration_or_recover' AND exit_seconds>0 AND exit_bpm BETWEEN 1 AND 65535)
)
)""".trimIndent(),
)
db.execSQL(
"""CREATE TABLE IF NOT EXISTS cardio_guidance_events(
run_id TEXT NOT NULL,
phase_id TEXT NOT NULL,
sequence INTEGER NOT NULL CHECK(sequence>=0),
observed_at TEXT NOT NULL,
instruction TEXT NOT NULL
CHECK(instruction IN('accelerate','maintain','slow_down','suspended')),
bpm INTEGER CHECK(bpm IS NULL OR bpm BETWEEN 1 AND 65535),
target_min_bpm INTEGER,
target_max_bpm INTEGER,
PRIMARY KEY(run_id,phase_id,sequence),
FOREIGN KEY(run_id,phase_id)
REFERENCES cardio_guidance_phases(run_id,phase_id) ON DELETE CASCADE,
CHECK(
(target_min_bpm IS NULL AND target_max_bpm IS NULL) OR
(target_min_bpm BETWEEN 1 AND 65535 AND
target_max_bpm BETWEEN target_min_bpm AND 65535)
)
)""".trimIndent(),
)
db.execSQL(
"""CREATE TABLE IF NOT EXISTS cardio_guidance_generation(
generation_id TEXT NOT NULL
REFERENCES sync_generations(generation_id) ON DELETE RESTRICT,
run_id TEXT NOT NULL
REFERENCES cardio_guidance_runs(run_id) ON DELETE RESTRICT,
PRIMARY KEY(generation_id,run_id)
)""".trimIndent(),
)
db.execSQL(
"CREATE INDEX IF NOT EXISTS cardio_guidance_session " +
"ON cardio_guidance_runs(session_id,started_at);",
)
db.execSQL(
"CREATE INDEX IF NOT EXISTS cardio_guidance_event_time " +
"ON cardio_guidance_events(observed_at,run_id,phase_id,sequence);",
)
}
private fun ensureCardioCalibrationExercise(db: SQLiteDatabase) {
db.execSQL(
"""INSERT OR IGNORE INTO exercises(

View file

@ -0,0 +1,98 @@
package com.labfytools.trainlog.model
enum class CardioPhaseKind(val wireValue: String) {
WARMUP("warmup"),
WORK("work"),
RECOVERY("recovery"),
COOLDOWN("cooldown"),
}
sealed interface CardioTargetDefinition {
data class AbsoluteBpm(
val minimumBpm: Int,
val maximumBpm: Int,
) : CardioTargetDefinition
data class CalibrationPercent(
val calibrationId: String,
val observedPeakBpm: Int,
val minimumPercent: Int,
val maximumPercent: Int,
) : CardioTargetDefinition
}
/**
* Immutable target actually used by one phase.
*
* The calibration identity and percentages are provenance only. Runtime
* guidance consumes the resolved BPM bounds so later calibration changes
* cannot rewrite an already-started session.
*/
data class CardioTargetSnapshot(
val minimumBpm: Int,
val maximumBpm: Int,
val calibrationId: String? = null,
val calibrationObservedPeakBpm: Int? = null,
val minimumPercent: Int? = null,
val maximumPercent: Int? = null,
)
sealed interface CardioPhaseExitCondition {
data class FixedDuration(val seconds: Int) : CardioPhaseExitCondition
data object EnterTarget : CardioPhaseExitCondition
data class RecoverBelow(val bpm: Int) : CardioPhaseExitCondition
data class DurationOrRecoverBelow(
val maximumSeconds: Int,
val bpm: Int,
) : CardioPhaseExitCondition
}
data class CardioGuidedPhase(
val phaseId: String,
val kind: CardioPhaseKind,
val target: CardioTargetSnapshot?,
val exitCondition: CardioPhaseExitCondition,
)
enum class CardioGuidanceInstruction {
ACCELERATE,
MAINTAIN,
SLOW_DOWN,
SUSPENDED,
}
data class CardioGuidanceOutput(
val instruction: CardioGuidanceInstruction,
val bpm: Int?,
val target: CardioTargetSnapshot?,
val pendingInstruction: CardioGuidanceInstruction? = null,
)
data class CardioGuidancePhaseRecord(
val runId: String,
val sessionId: String,
val entryId: String,
val phase: CardioGuidedPhase,
val position: Int,
val startedAt: String,
val endedAt: String?,
val currentInstruction: CardioGuidanceInstruction,
)
data class CardioGuidanceEvent(
val sequence: Long,
val observedAt: String,
val instruction: CardioGuidanceInstruction,
val bpm: Int?,
val targetMinimumBpm: Int?,
val targetMaximumBpm: Int?,
)
data class CardioGuidanceRun(
val runId: String,
val sessionId: String,
val startedAt: String,
val endedAt: String?,
val phases: List<CardioGuidancePhaseRecord>,
)

View file

@ -0,0 +1,385 @@
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.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.labfytools.trainlog.R
import com.labfytools.trainlog.data.CardioGuidanceLiveState
import com.labfytools.trainlog.data.CardioTargetResolver
import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.model.CardioGuidanceInstruction
import com.labfytools.trainlog.model.CardioGuidedPhase
import com.labfytools.trainlog.model.CardioPhaseExitCondition
import com.labfytools.trainlog.model.CardioPhaseKind
import com.labfytools.trainlog.model.CardioTargetDefinition
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
import java.util.UUID
@Composable
internal fun CardioGuidancePanel(
repository: TrainlogRepository,
entryId: String,
) {
val strings = localizedContext()
val colors = LocalTrainlogColors.current
var revision by remember(entryId) { mutableStateOf(0) }
val active =
remember(entryId, revision) {
repository.activeCardioGuidancePhase()?.takeIf { it.entryId == entryId }
}
val calibration =
remember(revision) {
repository.listCompletedCardioCalibrations(1).firstOrNull()
}
var live by remember { mutableStateOf(CardioGuidanceLiveState.current()) }
DisposableEffect(entryId) {
val subscription = CardioGuidanceLiveState.subscribe { live = it }
onDispose { subscription.close() }
}
TrainlogFrame(
title = strings.getString(R.string.cardio_guidance_title),
active = active != null,
) {
if (active != null) {
val phase = active.phase
val current = live.takeIf { it.phaseId == phase.phaseId }
val instruction = current?.instruction ?: active.currentInstruction
val bpm = current?.bpm
TrainlogInfo(
strings.getString(
R.string.cardio_guidance_phase_value,
phaseKindLabel(strings, phase.kind),
),
colors.muted,
)
phase.target?.let { target ->
TrainlogInfo(
strings.getString(
R.string.cardio_guidance_target_value,
target.minimumBpm,
target.maximumBpm,
),
colors.accent,
)
if (target.calibrationId != null) {
TrainlogInfo(
strings.getString(
R.string.cardio_guidance_calibration_value,
target.calibrationObservedPeakBpm ?: 0,
target.minimumPercent ?: 0,
target.maximumPercent ?: 0,
),
colors.muted,
)
}
}
TrainlogInfo(
guidanceInstructionLabel(strings, instruction),
when (instruction) {
CardioGuidanceInstruction.MAINTAIN -> colors.success
CardioGuidanceInstruction.ACCELERATE -> colors.accent
CardioGuidanceInstruction.SLOW_DOWN,
CardioGuidanceInstruction.SUSPENDED -> colors.warning
},
)
TrainlogInfo(
if (bpm == null) {
strings.getString(R.string.cardio_guidance_bpm_missing)
} else {
strings.getString(R.string.cardio_guidance_bpm_value, bpm)
},
if (bpm == null) colors.warning else colors.text,
)
TrainlogButton(
label = strings.getString(R.string.cardio_guidance_finish_phase),
onClick = {
if (
repository.finishCardioGuidancePhase(phase.phaseId) is
TrainlogRepository.CardioGuidanceMutationResult.Applied
) {
CardioGuidanceLiveState.clear()
revision++
}
},
modifier = Modifier.fillMaxWidth(),
style = TrainlogButtonStyle.SECONDARY,
)
return@TrainlogFrame
}
var kind by remember(entryId) { mutableStateOf(CardioPhaseKind.WARMUP) }
var targetMode by
remember(entryId, calibration?.calibrationId) {
mutableStateOf(if (calibration == null) "bpm" else "calibration")
}
var minimumBpm by remember(entryId) { mutableStateOf("") }
var maximumBpm by remember(entryId) { mutableStateOf("") }
var minimumPercent by remember(entryId) { mutableStateOf("") }
var maximumPercent by remember(entryId) { mutableStateOf("") }
var exitMode by remember(entryId) { mutableStateOf("duration") }
var durationSeconds by remember(entryId) { mutableStateOf("") }
var recoveryBpm by remember(entryId) { mutableStateOf("") }
var message by remember(entryId) { mutableStateOf<String?>(null) }
TrainlogChoiceChips(
choices =
listOf(
"warmup" to strings.getString(R.string.cardio_phase_warmup),
"work" to strings.getString(R.string.cardio_phase_work),
"recovery" to strings.getString(R.string.cardio_phase_recovery),
"cooldown" to strings.getString(R.string.cardio_phase_cooldown),
),
selectedId = kind.wireValue,
onSelected = { selected ->
kind = CardioPhaseKind.entries.first { it.wireValue == selected }
},
)
TrainlogChoiceChips(
choices =
buildList {
add("bpm" to strings.getString(R.string.cardio_target_absolute))
if (calibration?.observedPeakBpm != null) {
add(
"calibration" to
strings.getString(R.string.cardio_target_calibration),
)
}
},
selectedId = targetMode,
onSelected = { targetMode = it },
)
if (targetMode == "calibration") {
calibration?.observedPeakBpm?.let { peak ->
TrainlogInfo(
strings.getString(R.string.cardio_guidance_latest_calibration, peak),
colors.muted,
)
}
NumberPair(
leftLabel = strings.getString(R.string.cardio_target_min_percent),
left = minimumPercent,
onLeft = { minimumPercent = digits(it, 3) },
rightLabel = strings.getString(R.string.cardio_target_max_percent),
right = maximumPercent,
onRight = { maximumPercent = digits(it, 3) },
suffix = "%",
)
} else {
NumberPair(
leftLabel = strings.getString(R.string.cardio_target_min_bpm),
left = minimumBpm,
onLeft = { minimumBpm = digits(it, 5) },
rightLabel = strings.getString(R.string.cardio_target_max_bpm),
right = maximumBpm,
onRight = { maximumBpm = digits(it, 5) },
suffix = "BPM",
)
}
TrainlogChoiceChips(
choices =
listOf(
"duration" to strings.getString(R.string.cardio_exit_duration),
"target" to strings.getString(R.string.cardio_exit_target),
"recover" to strings.getString(R.string.cardio_exit_recover),
"bounded" to strings.getString(R.string.cardio_exit_bounded),
),
selectedId = exitMode,
onSelected = { exitMode = it },
)
if (exitMode == "duration" || exitMode == "bounded") {
TrainlogInputField(
label = strings.getString(R.string.cardio_exit_seconds),
value = durationSeconds,
onValueChange = { durationSeconds = digits(it, 5) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
suffix = "s",
compact = true,
)
}
if (exitMode == "recover" || exitMode == "bounded") {
TrainlogInputField(
label = strings.getString(R.string.cardio_exit_recovery_bpm),
value = recoveryBpm,
onValueChange = { recoveryBpm = digits(it, 5) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
suffix = "BPM",
compact = true,
)
}
val target =
if (targetMode == "calibration") {
val peak = calibration?.observedPeakBpm
val min = minimumPercent.toIntOrNull()
val max = maximumPercent.toIntOrNull()
if (calibration == null || peak == null || min == null || max == null) {
null
} else {
CardioTargetResolver.resolve(
CardioTargetDefinition.CalibrationPercent(
calibration.calibrationId,
peak,
min,
max,
),
)
}
} else {
val min = minimumBpm.toIntOrNull()
val max = maximumBpm.toIntOrNull()
if (min == null || max == null) null
else CardioTargetResolver.resolve(
CardioTargetDefinition.AbsoluteBpm(min, max),
)
}
target?.let {
TrainlogInfo(
strings.getString(
R.string.cardio_guidance_resolved_target,
it.minimumBpm,
it.maximumBpm,
),
colors.accent,
)
}
val exit =
when (exitMode) {
"duration" ->
durationSeconds.toIntOrNull()
?.takeIf { it in 1..86400 }
?.let(CardioPhaseExitCondition::FixedDuration)
"target" -> CardioPhaseExitCondition.EnterTarget
"recover" ->
recoveryBpm.toIntOrNull()
?.takeIf { it in 1..65535 }
?.let(CardioPhaseExitCondition::RecoverBelow)
"bounded" -> {
val seconds = durationSeconds.toIntOrNull()
val bpm = recoveryBpm.toIntOrNull()
if (seconds != null && bpm != null &&
seconds in 1..86400 && bpm in 1..65535
) {
CardioPhaseExitCondition.DurationOrRecoverBelow(seconds, bpm)
} else null
}
else -> null
}
message?.let { TrainlogInfo(it, colors.warning) }
TrainlogButton(
label = strings.getString(R.string.cardio_guidance_start_phase),
onClick = {
val actualTarget = target
val actualExit = exit
if (actualTarget == null || actualExit == null) {
message =
strings.getString(R.string.cardio_guidance_invalid_configuration)
return@TrainlogButton
}
val result =
repository.startCardioGuidancePhase(
entryId,
CardioGuidedPhase(
phaseId = "cgp_" + UUID.randomUUID(),
kind = kind,
target = actualTarget,
exitCondition = actualExit,
),
)
if (result is TrainlogRepository.CardioGuidanceMutationResult.Applied) {
message = null
revision++
} else {
message = strings.getString(R.string.cardio_guidance_start_failed)
}
},
modifier = Modifier.fillMaxWidth(),
enabled = target != null && exit != null,
style = TrainlogButtonStyle.SUCCESS,
)
}
}
@Composable
private fun NumberPair(
leftLabel: String,
left: String,
onLeft: (String) -> Unit,
rightLabel: String,
right: String,
onRight: (String) -> Unit,
suffix: String,
) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
TrainlogInputField(
label = leftLabel,
value = left,
onValueChange = onLeft,
modifier = Modifier.weight(1f),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
suffix = suffix,
compact = true,
)
TrainlogInputField(
label = rightLabel,
value = right,
onValueChange = onRight,
modifier = Modifier.weight(1f),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
suffix = suffix,
compact = true,
)
}
}
private fun phaseKindLabel(
strings: android.content.Context,
kind: CardioPhaseKind,
): String =
strings.getString(
when (kind) {
CardioPhaseKind.WARMUP -> R.string.cardio_phase_warmup
CardioPhaseKind.WORK -> R.string.cardio_phase_work
CardioPhaseKind.RECOVERY -> R.string.cardio_phase_recovery
CardioPhaseKind.COOLDOWN -> R.string.cardio_phase_cooldown
},
)
private fun guidanceInstructionLabel(
strings: android.content.Context,
instruction: CardioGuidanceInstruction,
): String =
strings.getString(
when (instruction) {
CardioGuidanceInstruction.ACCELERATE -> R.string.cardio_guidance_accelerate
CardioGuidanceInstruction.MAINTAIN -> R.string.cardio_guidance_maintain
CardioGuidanceInstruction.SLOW_DOWN -> R.string.cardio_guidance_slow_down
CardioGuidanceInstruction.SUSPENDED -> R.string.cardio_guidance_suspended
},
)
private fun digits(value: String, maximum: Int): String =
value.filter(Char::isDigit).take(maximum)

View file

@ -461,6 +461,16 @@ fun SessionScreen(
}
}
if (
currentDraft.sessionType == SessionType.CARDIO &&
timing?.active == true
) {
CardioGuidancePanel(
repository = repository,
entryId = draft.entryId,
)
}
TrainlogAction(
label = strings.getString(R.string.modify_name, draft.exercise.name),
description = strings.getString(R.string.edit_exercise_description),

View file

@ -585,4 +585,36 @@
<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>
<string name="cardio_guidance_title">Cardio guidance</string>
<string name="cardio_guidance_phase_value">Phase: %1$s</string>
<string name="cardio_guidance_target_value">Target: %1$d–%2$d BPM</string>
<string name="cardio_guidance_calibration_value">Calibration: observed peak %1$d BPM · %2$d–%3$d %%</string>
<string name="cardio_guidance_latest_calibration">Latest calibration: observed peak %1$d BPM</string>
<string name="cardio_guidance_resolved_target">Resolved target: %1$d–%2$d BPM</string>
<string name="cardio_guidance_accelerate">SPEED UP</string>
<string name="cardio_guidance_maintain">MAINTAIN</string>
<string name="cardio_guidance_slow_down">SLOW DOWN</string>
<string name="cardio_guidance_suspended">HEART-RATE SIGNAL UNAVAILABLE</string>
<string name="cardio_guidance_bpm_value">%1$d BPM</string>
<string name="cardio_guidance_bpm_missing">-- BPM</string>
<string name="cardio_guidance_start_phase">Start phase</string>
<string name="cardio_guidance_finish_phase">End phase</string>
<string name="cardio_guidance_invalid_configuration">Complete the target and exit condition.</string>
<string name="cardio_guidance_start_failed">Unable to start this phase.</string>
<string name="cardio_phase_warmup">Warm-up</string>
<string name="cardio_phase_work">Work</string>
<string name="cardio_phase_recovery">Recovery</string>
<string name="cardio_phase_cooldown">Cool-down</string>
<string name="cardio_target_absolute">BPM</string>
<string name="cardio_target_calibration">Calibration %%</string>
<string name="cardio_target_min_bpm">Min BPM</string>
<string name="cardio_target_max_bpm">Max BPM</string>
<string name="cardio_target_min_percent">Min %%</string>
<string name="cardio_target_max_percent">Max %%</string>
<string name="cardio_exit_duration">Duration</string>
<string name="cardio_exit_target">Enter target</string>
<string name="cardio_exit_recover">Below threshold</string>
<string name="cardio_exit_bounded">Duration or threshold</string>
<string name="cardio_exit_seconds">Max duration</string>
<string name="cardio_exit_recovery_bpm">Recovery threshold</string>
</resources>

View file

@ -595,4 +595,36 @@
<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>
<string name="cardio_guidance_title">Guidage cardio</string>
<string name="cardio_guidance_phase_value">Phase : %1$s</string>
<string name="cardio_guidance_target_value">Cible : %1$d–%2$d BPM</string>
<string name="cardio_guidance_calibration_value">Calibration : pic observé %1$d BPM · %2$d–%3$d %%</string>
<string name="cardio_guidance_latest_calibration">Dernière calibration : pic observé %1$d BPM</string>
<string name="cardio_guidance_resolved_target">Cible utilisée : %1$d–%2$d BPM</string>
<string name="cardio_guidance_accelerate">ACCÉLÈRE</string>
<string name="cardio_guidance_maintain">MAINTIENS</string>
<string name="cardio_guidance_slow_down">RALENTIS</string>
<string name="cardio_guidance_suspended">SIGNAL CARDIO INDISPONIBLE</string>
<string name="cardio_guidance_bpm_value">%1$d BPM</string>
<string name="cardio_guidance_bpm_missing">-- BPM</string>
<string name="cardio_guidance_start_phase">Démarrer la phase</string>
<string name="cardio_guidance_finish_phase">Terminer la phase</string>
<string name="cardio_guidance_invalid_configuration">Complète la cible et la condition de fin.</string>
<string name="cardio_guidance_start_failed">Impossible de démarrer cette phase.</string>
<string name="cardio_phase_warmup">Échauffement</string>
<string name="cardio_phase_work">Travail</string>
<string name="cardio_phase_recovery">Récupération</string>
<string name="cardio_phase_cooldown">Retour au calme</string>
<string name="cardio_target_absolute">BPM</string>
<string name="cardio_target_calibration">%% calibration</string>
<string name="cardio_target_min_bpm">BPM min</string>
<string name="cardio_target_max_bpm">BPM max</string>
<string name="cardio_target_min_percent">%% min</string>
<string name="cardio_target_max_percent">%% max</string>
<string name="cardio_exit_duration">Durée</string>
<string name="cardio_exit_target">Entrée dans la cible</string>
<string name="cardio_exit_recover">Sous un seuil</string>
<string name="cardio_exit_bounded">Durée ou seuil</string>
<string name="cardio_exit_seconds">Durée max</string>
<string name="cardio_exit_recovery_bpm">Seuil de récupération</string>
</resources>

View file

@ -66,7 +66,7 @@ class AndroidV10PlanningMigrationTest {
TrainlogRepository(context, name).useForTest { it.listSessions() }
SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
assertEquals(30, scalar(db, "PRAGMA user_version"))
assertEquals(31, scalar(db, "PRAGMA user_version"))
for (table in listOf("session_exercises", "draft_session_exercises")) {
assertEquals(6, scalar(db, "SELECT COUNT(*) FROM pragma_table_info('$table') WHERE name IN ('load_mode','rest_seconds','target_sets','target_reps','target_duration_seconds','target_weight_kg')"))
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM $table WHERE load_mode<>'none' OR rest_seconds<>0 OR target_sets IS NOT NULL OR target_reps IS NOT NULL OR target_duration_seconds IS NOT NULL OR target_weight_kg IS NOT NULL"))

View file

@ -69,7 +69,7 @@ class AndroidV11ExerciseAliasMigrationTest {
migrated.close()
}
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
assertEquals(30, scalar(db, "PRAGMA user_version"))
assertEquals(31, scalar(db, "PRAGMA user_version"))
/* v13 intentionally adds the frozen machine identities and
* exercise-owned metadata; all non-exercise facts stay exact. */
val after = snapshot(db).filterKeys { it !in setOf("exercise_aliases", "exercise_profile_state", "exercise_profile_revisions") }

View file

@ -78,7 +78,7 @@ class AndroidV16AiSessionDraftMigrationTest {
migrated.close()
}
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
assertEquals(30, scalar(db, "PRAGMA user_version"))
assertEquals(31, scalar(db, "PRAGMA user_version"))
assertEquals(before, listOf(
scalar(db, "SELECT COUNT(*) FROM sessions"),
scalar(db, "SELECT COUNT(*) FROM performed_sets"),

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -41,7 +41,7 @@ class AndroidV30CardioCalibrationMigrationTest {
null,
SQLiteDatabase.OPEN_READONLY,
).use { db ->
assertEquals(30, db.version)
assertEquals(31, db.version)
db.rawQuery(
"SELECT COUNT(*) FROM exercises WHERE " +
"exercise_id='ex_ca1b4a7e-1c2d-4f00-8a11-000000000001' " +

View file

@ -0,0 +1,65 @@
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 AndroidV31CardioGuidanceMigrationTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun v30AddsEmptyGuidanceDomainAndReopens() {
val name = "guidance-v30-" + UUID.randomUUID() + ".db"
var repository = TrainlogRepository(context, name)
try {
repository.listSessions()
repository.close()
SQLiteDatabase.openDatabase(
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READWRITE,
).use { db ->
db.execSQL("DROP TABLE cardio_guidance_generation")
db.execSQL("DROP TABLE cardio_guidance_events")
db.execSQL("DROP TABLE cardio_guidance_phases")
db.execSQL("DROP TABLE cardio_guidance_runs")
db.version = 30
}
repository = TrainlogRepository(context, name)
assertTrue(repository.activeCardioGuidancePhase() == null)
SQLiteDatabase.openDatabase(
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READONLY,
).use { db ->
assertEquals(31, db.version)
db.rawQuery(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN(" +
"'cardio_guidance_runs','cardio_guidance_phases'," +
"'cardio_guidance_events','cardio_guidance_generation')",
null,
).use { c ->
assertTrue(c.moveToFirst())
assertEquals(4, c.getInt(0))
}
db.rawQuery("SELECT COUNT(*) FROM cardio_guidance_runs", null).use { c ->
assertTrue(c.moveToFirst())
assertEquals(0, c.getInt(0))
}
}
} finally {
repository.close()
context.deleteDatabase(name)
}
}
}

View file

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

View file

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

View file

@ -0,0 +1,147 @@
package com.labfytools.trainlog.data
import com.labfytools.trainlog.model.CardioGuidanceInstruction
import com.labfytools.trainlog.model.CardioTargetDefinition
import com.labfytools.trainlog.model.CardioTargetSnapshot
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class CardioGuidanceEngineTest {
private val target = CardioTargetSnapshot(120, 140)
@Test
fun absoluteAndCalibrationTargetsResolveToImmutableBpmSnapshot() {
assertEquals(
CardioTargetSnapshot(120, 140),
CardioTargetResolver.resolve(CardioTargetDefinition.AbsoluteBpm(120, 140)),
)
assertEquals(
CardioTargetSnapshot(
minimumBpm = 100,
maximumBpm = 125,
calibrationId = "cal_11111111-1111-4111-8111-111111111111",
calibrationObservedPeakBpm = 167,
minimumPercent = 60,
maximumPercent = 75,
),
CardioTargetResolver.resolve(
CardioTargetDefinition.CalibrationPercent(
calibrationId = "cal_11111111-1111-4111-8111-111111111111",
observedPeakBpm = 167,
minimumPercent = 60,
maximumPercent = 75,
),
),
)
}
@Test
fun invalidTargetDefinitionsAreRejectedInsteadOfClamped() {
assertNull(CardioTargetResolver.resolve(CardioTargetDefinition.AbsoluteBpm(140, 120)))
assertNull(
CardioTargetResolver.resolve(
CardioTargetDefinition.CalibrationPercent("cal_x", 0, 60, 70),
),
)
assertNull(
CardioTargetResolver.resolve(
CardioTargetDefinition.CalibrationPercent("cal_x", 160, 80, 60),
),
)
}
@Test
fun lowBpmMustPersistBeforeAccelerateInstructionChanges() {
val engine = CardioGuidanceEngine(hysteresisBpm = 2, confirmationMs = 3_000)
assertEquals(
CardioGuidanceInstruction.MAINTAIN,
engine.evaluate(target, 110, true, 1_000).instruction,
)
assertEquals(
CardioGuidanceInstruction.MAINTAIN,
engine.evaluate(target, 111, true, 3_999).instruction,
)
val changed = engine.evaluate(target, 111, true, 4_000)
assertEquals(CardioGuidanceInstruction.ACCELERATE, changed.instruction)
assertNull(changed.pendingInstruction)
}
@Test
fun highBpmMustPersistBeforeSlowDownInstructionChanges() {
val engine = CardioGuidanceEngine(hysteresisBpm = 2, confirmationMs = 2_000)
engine.evaluate(target, 150, true, 0)
assertEquals(
CardioGuidanceInstruction.SLOW_DOWN,
engine.evaluate(target, 150, true, 2_000).instruction,
)
}
@Test
fun hysteresisPreventsBoundaryYoyo() {
val engine = CardioGuidanceEngine(hysteresisBpm = 2, confirmationMs = 0)
assertEquals(
CardioGuidanceInstruction.ACCELERATE,
engine.evaluate(target, 117, true, 0).instruction,
)
// 119 is still below target, but not enough to leave ACCELERATE.
assertEquals(
CardioGuidanceInstruction.ACCELERATE,
engine.evaluate(target, 119, true, 1).instruction,
)
assertEquals(
CardioGuidanceInstruction.MAINTAIN,
engine.evaluate(target, 122, true, 2).instruction,
)
// 141 is just above the zone and remains MAINTAIN within hysteresis.
assertEquals(
CardioGuidanceInstruction.MAINTAIN,
engine.evaluate(target, 141, true, 3).instruction,
)
assertEquals(
CardioGuidanceInstruction.SLOW_DOWN,
engine.evaluate(target, 143, true, 4).instruction,
)
}
@Test
fun isolatedOppositeSampleDoesNotReplaceCurrentInstruction() {
val engine = CardioGuidanceEngine(hysteresisBpm = 2, confirmationMs = 3_000)
engine.evaluate(target, 110, true, 0)
engine.evaluate(target, 110, true, 3_000)
assertEquals(
CardioGuidanceInstruction.ACCELERATE,
engine.evaluate(target, 150, true, 3_100).instruction,
)
assertEquals(
CardioGuidanceInstruction.ACCELERATE,
engine.evaluate(target, 112, true, 3_200).instruction,
)
}
@Test
fun staleOrMissingSignalSuspendsImmediatelyAndNeverUsesOldBpm() {
val engine = CardioGuidanceEngine(hysteresisBpm = 2, confirmationMs = 0)
assertEquals(
CardioGuidanceInstruction.ACCELERATE,
engine.evaluate(target, 110, true, 0).instruction,
)
val stale = engine.evaluate(target, 110, false, 1_000)
assertEquals(CardioGuidanceInstruction.SUSPENDED, stale.instruction)
assertNull(stale.bpm)
// A fresh sample must re-establish a candidate from real current data.
val resumed = engine.evaluate(target, 130, true, 2_000)
assertTrue(resumed.instruction != CardioGuidanceInstruction.SUSPENDED)
assertEquals(130, resumed.bpm)
}
@Test
fun noTargetSuspendsGuidance() {
val engine = CardioGuidanceEngine()
val output = engine.evaluate(null, 130, true, 0)
assertEquals(CardioGuidanceInstruction.SUSPENDED, output.instruction)
assertNull(output.bpm)
}
}

View file

@ -0,0 +1,260 @@
package com.labfytools.trainlog.data
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.ActiveSessionDraft
import com.labfytools.trainlog.model.CardioGuidanceInstruction
import com.labfytools.trainlog.model.CardioGuidanceOutput
import com.labfytools.trainlog.model.CardioGuidedPhase
import com.labfytools.trainlog.model.CardioPhaseExitCondition
import com.labfytools.trainlog.model.CardioPhaseKind
import com.labfytools.trainlog.model.CardioTargetSnapshot
import com.labfytools.trainlog.model.NewExerciseProfile
import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.SessionExerciseDraft
import com.labfytools.trainlog.model.SessionExercisePlan
import com.labfytools.trainlog.model.SessionType
import com.labfytools.trainlog.model.TrackingMode
import java.nio.file.Files
import java.time.OffsetDateTime
import java.util.UUID
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class CardioGuidanceGenerationTest {
private val context: Context = ApplicationProvider.getApplicationContext()
private fun measurement(bpm: Int) =
ParsedHeartRateMeasurement(
bpm = bpm,
sensorContactDetected = null,
energyExpended = null,
rrIntervals1024 = listOf(1024),
)
@Test
fun completedGuidanceSynchronizesOnceAndAckStopsRepublication() {
val name = "guidance-generation-" + UUID.randomUUID() + ".db"
val repository = TrainlogRepository(context, name)
val root = Files.createTempDirectory("trainlog-guidance-generation-").toFile()
try {
val exercise =
(repository.createExercise(
NewExerciseProfile(
"Vélo guidé sync",
RecordingMode.CONTINUOUS,
TrackingMode.DURATION,
0,
),
) as CreateExerciseResult.Created).exercise
val entry =
SessionExerciseDraft(
exercise = exercise,
plan = SessionExercisePlan(sets = 0, durationSeconds = 30),
)
assertEquals(
ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(
ActiveSessionDraft(
startedAt = OffsetDateTime.now().minusMinutes(1).toString(),
sessionType = SessionType.CARDIO,
exercises = listOf(entry),
),
),
)
val active =
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
val base = OffsetDateTime.parse(checkNotNull(active.startedAt))
val exerciseStart = base.plusSeconds(1).toString()
assertTrue(
repository.startActiveSessionExercise(entry.entryId, exerciseStart) is
TrainlogRepository.SessionExerciseTimingResult.Started,
)
val target = CardioTargetSnapshot(120, 140)
val phase =
CardioGuidedPhase(
phaseId = "cgp_" + UUID.randomUUID(),
kind = CardioPhaseKind.WORK,
target = target,
exitCondition = CardioPhaseExitCondition.FixedDuration(30),
)
assertTrue(
repository.startCardioGuidancePhase(
entry.entryId,
phase,
base.plusSeconds(2).toString(),
) is TrainlogRepository.CardioGuidanceMutationResult.Applied,
)
val lowAt = base.plusSeconds(3).toString()
repository.recordLiveHeartRateForActiveSession(
"Synthetic HR",
lowAt,
measurement(110),
)
repository.recordCardioGuidanceOutput(
phase.phaseId,
lowAt,
CardioGuidanceOutput(
CardioGuidanceInstruction.ACCELERATE,
110,
target,
),
)
val targetAt = base.plusSeconds(4).toString()
repository.recordLiveHeartRateForActiveSession(
"Synthetic HR",
targetAt,
measurement(130),
)
repository.recordCardioGuidanceOutput(
phase.phaseId,
targetAt,
CardioGuidanceOutput(
CardioGuidanceInstruction.MAINTAIN,
130,
target,
),
)
repository.finishCardioGuidancePhase(
phase.phaseId,
base.plusSeconds(5).toString(),
)
repository.finishActiveSessionExercise(
entry.entryId,
base.plusSeconds(6).toString(),
)
val completedDraft =
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
assertEquals(
ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(
completedDraft.copy(
exercises =
completedDraft.exercises.map {
if (it.entryId == entry.entryId) {
it.copy(continuousDurationSeconds = 5)
} else {
it
}
},
),
),
)
assertTrue(repository.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved)
assertEquals(
1,
JSONObject(repository.buildCardioGuidanceV1Json())
.getJSONArray("runs")
.length(),
)
val repositoryRoot =
generateSequence(java.io.File(checkNotNull(System.getProperty("user.dir")))) {
it.parentFile
}
.first { java.io.File(it, "tools/sync_generation_exchange.py").isFile }
fun run(vararg command: String): String {
val process =
ProcessBuilder(*command)
.directory(repositoryRoot)
.redirectErrorStream(true)
.start()
val output = process.inputStream.bufferedReader().readText()
assertEquals(
"command failed: ${command.joinToString(" ")}\n$output",
0,
process.waitFor(),
)
return output
}
val desktopDb = java.io.File(root, "desktop.sqlite")
run(
java.io.File(repositoryRoot, "build/tui/sync-generation-fixture").absolutePath,
desktopDb.absolutePath,
)
val desktopPeer =
Regex("PEER_ID=(peer_[^\\s]+)")
.find(
run(
"python3",
java.io.File(
repositoryRoot,
"tools/sync_generation_exchange.py",
).absolutePath,
"peer-id",
"--database",
desktopDb.absolutePath,
),
)!!
.groupValues[1]
val service = SyncGenerationService(repository)
val captured = service.capture(root, desktopPeer)
assertEquals(
1,
JSONObject(
java.io.File(
captured.stagingDirectory,
"cardio-guidance-v1.json",
).readText(),
)
.getJSONArray("runs")
.length(),
)
val published =
service.publish(captured, java.io.File(root, "objects"))
val ackFile = java.io.File(root, "ack.json")
run(
"python3",
java.io.File(repositoryRoot, "tools/sync_generation_exchange.py").absolutePath,
"consume-desktop",
published.absolutePath,
"--database",
desktopDb.absolutePath,
"--ack-output",
ackFile.absolutePath,
)
assertEquals(
"consumed",
JSONObject(ackFile.readText()).getString("result"),
)
assertEquals(
"acknowledged",
service.acceptAcknowledgement(ackFile.readBytes()),
)
assertEquals(
0,
JSONObject(repository.buildCardioGuidanceV1Json())
.getJSONArray("runs")
.length(),
)
assertEquals(
"1|1|2",
run(
"sqlite3",
desktopDb.absolutePath,
"SELECT (SELECT count(*) FROM cardio_guidance_runs) || '|' || " +
"(SELECT count(*) FROM cardio_guidance_phases) || '|' || " +
"(SELECT count(*) FROM cardio_guidance_events);",
)
.trim(),
)
} finally {
repository.close()
context.deleteDatabase(name)
root.deleteRecursively()
}
}
}

View file

@ -0,0 +1,231 @@
package com.labfytools.trainlog.data
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.ActiveSessionDraft
import com.labfytools.trainlog.model.CardioGuidanceInstruction
import com.labfytools.trainlog.model.CardioGuidanceOutput
import com.labfytools.trainlog.model.CardioGuidedPhase
import com.labfytools.trainlog.model.CardioPhaseExitCondition
import com.labfytools.trainlog.model.CardioPhaseKind
import com.labfytools.trainlog.model.CardioTargetSnapshot
import com.labfytools.trainlog.model.NewExerciseProfile
import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.SessionExerciseDraft
import com.labfytools.trainlog.model.SessionExercisePlan
import com.labfytools.trainlog.model.SessionType
import com.labfytools.trainlog.model.TrackingMode
import java.time.OffsetDateTime
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 CardioGuidanceRepositoryTest {
private val context: Context = ApplicationProvider.getApplicationContext()
private data class ActiveCardio(
val repository: TrainlogRepository,
val databaseName: String,
val entryId: String,
val exerciseStartedAt: String,
) {
fun at(seconds: Long): String =
OffsetDateTime.parse(exerciseStartedAt).plusSeconds(seconds).toString()
}
private fun activeCardio(): ActiveCardio {
val name = "guidance-" + UUID.randomUUID() + ".db"
val repository = TrainlogRepository(context, name)
val exercise =
(repository.createExercise(
NewExerciseProfile(
"Tapis guidé",
RecordingMode.CONTINUOUS,
TrackingMode.DURATION,
0,
),
) as CreateExerciseResult.Created).exercise
val entry =
SessionExerciseDraft(
exercise = exercise,
plan = SessionExercisePlan(sets = 0, durationSeconds = 600),
)
assertEquals(
ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(
ActiveSessionDraft(
startedAt = OffsetDateTime.now().minusMinutes(20).toString(),
sessionType = SessionType.CARDIO,
exercises = listOf(entry),
),
),
)
val loaded = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
val exerciseStartedAt =
OffsetDateTime.parse(checkNotNull(loaded.draft.startedAt))
.plusSeconds(10)
.toString()
val started =
repository.startActiveSessionExercise(
entry.entryId,
exerciseStartedAt,
)
assertTrue(started is TrainlogRepository.SessionExerciseTimingResult.Started)
return ActiveCardio(repository, name, entry.entryId, exerciseStartedAt)
}
@Test
fun phaseRequiresActiveCardioExerciseAndRecordsOnlyInstructionChanges() {
val active = activeCardio()
val repository = active.repository
try {
val target = CardioTargetSnapshot(120, 140)
val phase =
CardioGuidedPhase(
phaseId = "cgp_" + UUID.randomUUID(),
kind = CardioPhaseKind.WORK,
target = target,
exitCondition = CardioPhaseExitCondition.FixedDuration(180),
)
val started =
repository.startCardioGuidancePhase(
active.entryId,
phase,
active.at(10),
) as TrainlogRepository.CardioGuidanceMutationResult.Applied
assertEquals(CardioGuidanceInstruction.MAINTAIN, started.phase!!.currentInstruction)
repository.recordCardioGuidanceOutput(
phase.phaseId,
active.at(13),
CardioGuidanceOutput(
CardioGuidanceInstruction.ACCELERATE,
110,
target,
),
)
repository.recordCardioGuidanceOutput(
phase.phaseId,
active.at(14),
CardioGuidanceOutput(
CardioGuidanceInstruction.ACCELERATE,
111,
target,
),
)
repository.recordCardioGuidanceOutput(
phase.phaseId,
active.at(30),
CardioGuidanceOutput(
CardioGuidanceInstruction.MAINTAIN,
130,
target,
),
)
assertEquals(
listOf(
CardioGuidanceInstruction.ACCELERATE,
CardioGuidanceInstruction.MAINTAIN,
),
repository.listCardioGuidanceEvents(phase.phaseId).map { it.instruction },
)
repository.finishActiveSessionExercise(
active.entryId,
active.at(600),
)
assertNull(repository.activeCardioGuidancePhase())
val completedDraft =
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
assertEquals(
ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(
completedDraft.copy(
exercises =
completedDraft.exercises.map {
if (it.entryId == active.entryId) {
it.copy(continuousDurationSeconds = 600)
} else {
it
}
},
),
),
)
val finalized = repository.finalizeActiveSessionDraft()
assertTrue(finalized is FinalizeActiveDraftResult.Saved)
} finally {
repository.close()
context.deleteDatabase(active.databaseName)
}
}
@Test
fun guidanceCannotStartBeforeExerciseTimingIsActive() {
val name = "guidance-inactive-" + UUID.randomUUID() + ".db"
val repository = TrainlogRepository(context, name)
try {
val exercise =
(repository.createExercise(
NewExerciseProfile(
"Vélo guidé",
RecordingMode.CONTINUOUS,
TrackingMode.DURATION,
0,
),
) as CreateExerciseResult.Created).exercise
repository.startActiveSessionDraft()
val loaded = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
val entry = SessionExerciseDraft(exercise = exercise, continuousDurationSeconds = 60)
repository.saveActiveSessionDraft(
loaded.draft.copy(sessionType = SessionType.CARDIO, exercises = listOf(entry)),
)
val result =
repository.startCardioGuidancePhase(
entry.entryId,
CardioGuidedPhase(
"cgp_" + UUID.randomUUID(),
CardioPhaseKind.WARMUP,
CardioTargetSnapshot(100, 115),
CardioPhaseExitCondition.FixedDuration(60),
),
"2026-09-23T13:00:00+02:00",
)
assertTrue(result is TrainlogRepository.CardioGuidanceMutationResult.Invalid)
} finally {
repository.close()
context.deleteDatabase(name)
}
}
@Test
fun abandonedDraftDeletesOnlyUnfinishedGuidanceRun() {
val active = activeCardio()
val repository = active.repository
try {
repository.startCardioGuidancePhase(
active.entryId,
CardioGuidedPhase(
"cgp_" + UUID.randomUUID(),
CardioPhaseKind.WORK,
CardioTargetSnapshot(120, 140),
CardioPhaseExitCondition.FixedDuration(120),
),
active.at(10),
)
assertTrue(repository.activeCardioGuidancePhase() != null)
repository.discardActiveSessionDraft()
assertNull(repository.activeCardioGuidancePhase())
} finally {
repository.close()
context.deleteDatabase(active.databaseName)
}
}
}

View file

@ -0,0 +1,67 @@
package com.labfytools.trainlog.data
import com.labfytools.trainlog.model.CardioGuidedPhase
import com.labfytools.trainlog.model.CardioPhaseExitCondition
import com.labfytools.trainlog.model.CardioPhaseKind
import com.labfytools.trainlog.model.CardioTargetSnapshot
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class CardioPhaseCompletionTest {
private val target = CardioTargetSnapshot(120, 140)
private fun phase(
exit: CardioPhaseExitCondition,
targetOverride: CardioTargetSnapshot? = target,
) = CardioGuidedPhase(
phaseId = "phase_test",
kind = CardioPhaseKind.WORK,
target = targetOverride,
exitCondition = exit,
)
@Test
fun fixedDurationUsesElapsedTimeOnly() {
val p = phase(CardioPhaseExitCondition.FixedDuration(180))
assertFalse(CardioPhaseCompletion.isComplete(p, 179, null, false))
assertTrue(CardioPhaseCompletion.isComplete(p, 180, null, false))
}
@Test
fun enterTargetRequiresFreshMeasurementInsideSnapshot() {
val p = phase(CardioPhaseExitCondition.EnterTarget)
assertFalse(CardioPhaseCompletion.isComplete(p, 10, 130, false))
assertFalse(CardioPhaseCompletion.isComplete(p, 10, 119, true))
assertTrue(CardioPhaseCompletion.isComplete(p, 10, 120, true))
assertTrue(CardioPhaseCompletion.isComplete(p, 10, 140, true))
}
@Test
fun enterTargetWithoutTargetNeverCompletes() {
val p = phase(CardioPhaseExitCondition.EnterTarget, null)
assertFalse(CardioPhaseCompletion.isComplete(p, 100, 130, true))
}
@Test
fun recoveryThresholdRequiresFreshMeasurement() {
val p = phase(CardioPhaseExitCondition.RecoverBelow(110), null)
assertFalse(CardioPhaseCompletion.isComplete(p, 30, 105, false))
assertFalse(CardioPhaseCompletion.isComplete(p, 30, 111, true))
assertTrue(CardioPhaseCompletion.isComplete(p, 30, 110, true))
}
@Test
fun durationOrRecoveryCanFinishFromEitherRealCondition() {
val p = phase(CardioPhaseExitCondition.DurationOrRecoverBelow(300, 110), null)
assertFalse(CardioPhaseCompletion.isComplete(p, 299, 120, true))
assertTrue(CardioPhaseCompletion.isComplete(p, 100, 108, true))
assertTrue(CardioPhaseCompletion.isComplete(p, 300, null, false))
}
@Test
fun negativeElapsedTimeCannotCompleteAnything() {
val p = phase(CardioPhaseExitCondition.FixedDuration(0))
assertFalse(CardioPhaseCompletion.isComplete(p, -1, 130, true))
}
}

View file

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

View file

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

View file

@ -2260,7 +2260,7 @@ class TrainlogRepositoryDraftTest {
).use { db ->
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(30, cursor.getInt(0))
assertEquals(31, 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(30, cursor.getInt(0))
assertEquals(31, cursor.getInt(0))
}
db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor ->
assertTrue(cursor.moveToFirst())

View file

@ -112,8 +112,8 @@ parallel implementation of its rules.
| Boundary | Current state |
|---|---|
| Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` |
| Desktop SQLite | schema 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 |
| Desktop SQLite | schema v34; synchronized guided-cardio runs, phase snapshots and instruction changes are additive in v34 |
| Android SQLite | schema v31; Android owns durable guided-cardio runs/phases/events in addition to calibration 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` |
@ -154,7 +154,8 @@ parallel implementation of its rules.
| Sleep Android one-tap | `TRAINLOG_SLEEP_ANDROID_ONE_TAP_V1=SOFTWARE_PASS / DEVICE_VISUAL_PENDING`: existing Sleep Diary V1 now has one-tap Couché / medication / Réveil / Levé, guarded 10 s undo, full correction route and sleep-entry-owned cardio capture; vc41 installed without data clear, but final visual device review was blocked by the phone lock screen |
| Cardio session type | `TRAINLOG_CARDIO_SESSION_TYPE_V1=PASS`: Android v29 + desktop v32 use canonical `session_kind=cardio`; frozen mobile/history and draft formats keep legacy training/max_test; dedicated `trainlog-cardio-sessions` V1 syncs completed cardio sessions Android→desktop idempotently |
| Cardio 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 |
| Cardio BPM guidance | `TRAINLOG_CARDIO_BPM_GUIDANCE_V1=PASS`: absolute/calibration-derived immutable BPM targets, 2 BPM hysteresis, 3 s confirmation, stale-signal suspension, durable phase/condition history, Android live guidance UI and strict Android→desktop `trainlog-cardio-guidance` V1 sync |
| Current operational cursor | `TRAINLOG_CARDIO_WEB_VISUALIZATION_V1`: factual Web timelines/curves for training, cardio guidance/calibration and Sleep without inventing measurements or sleep states |
| Complete synchronization gap contract | Frozen dependency contract; operational USB/Drive slices required by v0.1.2 are delivered |
| Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` |
| Synchronization characterization | `TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` |

View file

@ -1,5 +1,13 @@
# Desktop database
## Schema v34: synchronized Cardio guidance V1
Schema v34 additively stores completed Android-owned guided-cardio runs. `cardio_guidance_runs` keeps the stable run/session identity and exact run bounds. `cardio_guidance_phases` stores the ordered `entry_id`-owned phase, its immutable resolved BPM target, optional exact calibration provenance, exit condition, phase bounds and final instruction. `cardio_guidance_events` stores only instruction changes, not another copy of the raw heart-rate curve.
The importer requires the referenced canonical session to be `cardio`, every phase `entry_id` to exist in that session, and every phase interval to remain inside the synchronized exercise timeline. Calibration-derived targets must reference an imported calibration with the same observed peak. Any guidance event carrying BPM must match an exact synchronized raw heart-rate sample at the same timestamp; suspended events intentionally carry no BPM.
No migration invents phases, instructions, BPM or calibration references.
## 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.

View file

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

View file

@ -1135,7 +1135,15 @@ Before import, desktop proves that the referenced session/occurrence and HR capt
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
## 23. Cardio guidance companion V1
`trainlog-cardio-guidance` V1 is an optional Android→desktop full-generation participant containing only completed guided-cardio runs. Each run owns ordered phases, the exact target snapshot used at execution time, its exit condition and only the chronological instruction changes (`accelerate`, `maintain`, `slow_down`, `suspended`). Raw BPM/RR remain exclusively in Heart Rate V1.
A calibration-derived target carries `calibration_id`, the observed peak snapshot and the percentages that resolved the BPM bounds. Desktop requires that reference to match an already imported completed calibration. Every non-suspended instruction event carrying BPM must match an exact raw heart-rate sample for the same cardio session and timestamp. Suspended events carry no BPM and therefore cannot silently reuse a stale measurement.
Exact replay is idempotent. Reusing a stable run/phase identity with different target, condition, interval or event history is rejected. Android records exact `run_id -> generation_id` membership; only a correlated consumed ACK marks the run acknowledged and excludes it from ordinary future publication.
## 24. Legacy active-protocol limitations
The Web end-to-end tranche adds an explicit opt-in application orchestrator.
`POST /api/v1/sync` admits one correlated run and `GET /api/v1/sync/status` is

View file

@ -1808,3 +1808,32 @@ The calibration system exercise keeps a stable `exercise_id` for occurrence fore
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.
## Cardio BPM guidance V1 validation
`TRAINLOG_CARDIO_BPM_GUIDANCE_V1=PASS` covers Android schema v31, desktop
schema v34, pure guidance logic, persistent phase/run history, live CYCPLUS
integration, strict synchronization and the Android active-session UI.
The guidance engine resolves either absolute BPM targets or calibration-relative
percentages into an immutable BPM snapshot before execution. The default
control-stability parameters are a 2 BPM hysteresis band and a 3 second
confirmation interval; these are UI/control parameters, not physiological zone
claims. A stale or missing heart-rate signal suspends guidance immediately and
never reuses the last BPM.
Supported phase exit conditions are fixed duration, entry into the resolved BPM
target, recovery below a threshold, and duration-or-recovery. Only time-based
conditions can complete without a fresh BPM measurement.
Android records ordered phases against the active stable cardio `entry_id`,
persists target/calibration provenance and stores only instruction changes.
The full-generation regression proves a completed run with raw BPM evidence is
imported through `trainlog-cardio-guidance` V1, receives a correlated consumed
ACK, and is excluded from the next ordinary Android publication. Desktop
independently verifies canonical cardio session ownership, exercise timeline,
calibration provenance and exact raw BPM evidence for non-suspended events.
The 2026-09-23 closeout passes the complete native Meson inventory
**110/110**, and the complete Android JVM inventory reports **300 tests:
295 passed, five skipped, zero failures/errors**. `assembleDebug` succeeds.

View file

@ -0,0 +1,236 @@
#!/usr/bin/env python3
import sys
import tempfile
import subprocess
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_guidance_exchange as guidance
from trainlog_sqlite import connect_database
RUN = "cgr_11111111-1111-4111-8111-111111111111"
PHASE = "cgp_22222222-2222-4222-8222-222222222222"
SESSION = "se_33333333-3333-4333-8333-333333333333"
ENTRY = "sxe_44444444-4444-4444-8444-444444444444"
EXERCISE = "ex_55555555-5555-4555-8555-555555555555"
CAPTURE = "hrc_66666666-6666-4666-8666-666666666666"
CAL = "cal_77777777-7777-4777-8777-777777777777"
def document(calibrated: bool = False) -> dict:
target = {
"minimum_bpm": 120,
"maximum_bpm": 140,
"calibration_id": CAL if calibrated else None,
"calibration_observed_peak_bpm": 160 if calibrated else None,
"minimum_percent": 75 if calibrated else None,
"maximum_percent": 88 if calibrated else None,
}
return {
"format": "trainlog-cardio-guidance",
"version": 1,
"generated_at": "2026-09-23T13:11:00+02:00",
"runs": [
{
"run_id": RUN,
"session_id": SESSION,
"started_at": "2026-09-23T13:00:20+02:00",
"ended_at": "2026-09-23T13:03:20+02:00",
"phases": [
{
"phase_id": PHASE,
"entry_id": ENTRY,
"position": 0,
"kind": "work",
"target": target,
"exit_condition": {
"kind": "fixed_duration",
"seconds": 180,
"bpm": None,
},
"started_at": "2026-09-23T13:00:20+02:00",
"ended_at": "2026-09-23T13:03:20+02:00",
"final_instruction": "maintain",
"events": [
{
"sequence": 0,
"observed_at": "2026-09-23T13:00:23+02:00",
"instruction": "accelerate",
"bpm": 110,
"target_minimum_bpm": 120,
"target_maximum_bpm": 140,
},
{
"sequence": 1,
"observed_at": "2026-09-23T13:00:30+02:00",
"instruction": "suspended",
"bpm": None,
"target_minimum_bpm": 120,
"target_maximum_bpm": 140,
},
{
"sequence": 2,
"observed_at": "2026-09-23T13:00:40+02:00",
"instruction": "maintain",
"bpm": 130,
"target_minimum_bpm": 120,
"target_maximum_bpm": 140,
},
],
}
],
}
],
}
class CardioGuidanceExchangeTest(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, "Tapis guidé", "tapis guide", "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-23T13:00:00+02:00",
"2026-09-23T13:10: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 session_exercise_timeline("
"session_id,entry_id,exercise_id,started_at,ended_at,imported_at"
") VALUES(?,?,?,?,?,?)",
(
SESSION,
ENTRY,
EXERCISE,
"2026-09-23T13:00:10+02:00",
"2026-09-23T13:10:10+02:00",
"2026-09-23T13:11:00+02:00",
),
)
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-23T13:00:00+02:00",
"2026-09-23T13:10:10+02:00",
"CYCPLUS H2",
"2026-09-23T13:11:00+02:00",
),
)
self.db.executemany(
"INSERT INTO heart_rate_samples("
"capture_id,sequence,observed_at,bpm,exercise_entry_id,"
"sensor_contact_detected,energy_expended) VALUES(?,?,?,?,?,?,?)",
[
(CAPTURE, 0, "2026-09-23T13:00:23+02:00", 110, ENTRY, None, None),
(CAPTURE, 1, "2026-09-23T13:00:40+02:00", 130, ENTRY, None, None),
],
)
self.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(?,?,?,?,?,?,?,?,?,?)",
(
CAL,
1,
SESSION,
ENTRY,
"2026-09-23T13:00:00+02:00",
"2026-09-23T13:02:00+02:00",
"2026-09-23T13:03:00+02:00",
CAPTURE,
160,
"2026-09-23T13:11:00+02:00",
),
)
self.db.commit()
def tearDown(self):
self.db.close()
self.temp.cleanup()
def test_import_and_exact_replay_preserve_snapshot_and_events(self):
payload = guidance.validate(document())
self.assertEqual((1, 0), guidance.apply(self.db, payload))
self.assertEqual((0, 1), guidance.apply(self.db, payload))
self.assertEqual(
(SESSION, "2026-09-23T13:00:20+02:00", "2026-09-23T13:03:20+02:00"),
tuple(
self.db.execute(
"SELECT session_id,started_at,ended_at FROM cardio_guidance_runs WHERE run_id=?",
(RUN,),
).fetchone()
),
)
self.assertEqual(
[(0, "accelerate", 110), (1, "suspended", None), (2, "maintain", 130)],
[
tuple(row)
for row in self.db.execute(
"SELECT sequence,instruction,bpm FROM cardio_guidance_events "
"WHERE run_id=? AND phase_id=? ORDER BY sequence",
(RUN, PHASE),
)
],
)
def test_calibration_provenance_must_match_imported_profile(self):
payload = guidance.validate(document(calibrated=True))
self.assertEqual((1, 0), guidance.apply(self.db, payload))
changed = deepcopy(document(calibrated=True))
changed["runs"][0]["phases"][0]["target"]["calibration_observed_peak_bpm"] = 159
with self.assertRaisesRegex(ValueError, "calibration provenance"):
guidance.apply(self.db, guidance.validate(changed))
def test_guidance_bpm_must_exist_in_raw_capture(self):
changed = deepcopy(document())
changed["runs"][0]["phases"][0]["events"][0]["bpm"] = 109
with self.assertRaisesRegex(ValueError, "not a raw heart-rate sample"):
guidance.apply(self.db, guidance.validate(changed))
def test_identity_reuse_with_different_event_is_rejected(self):
payload = guidance.validate(document())
guidance.apply(self.db, payload)
changed = deepcopy(document())
changed["runs"][0]["phases"][0]["final_instruction"] = "slow_down"
with self.assertRaisesRegex(ValueError, "different content"):
guidance.apply(self.db, guidance.validate(changed))
if __name__ == "__main__":
unittest.main()

View file

@ -66,14 +66,15 @@ 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"], 33)
self.assertEqual(inventory["android_schema"], 30)
self.assertEqual(inventory["desktop_schema"], 34)
self.assertEqual(inventory["android_schema"], 31)
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("cardio-guidance-v1", inventory["protocols"])
self.assertIn("heart-rate-v1", inventory["protocols"])
self.assertIn("session-timeline-v1", inventory["protocols"])
self.assertIn("bluetooth-files-v1", inventory["protocols"])

View file

@ -271,8 +271,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] != 33:
fail("desktop schema v33 required")
if db.execute("PRAGMA user_version").fetchone()[0] not in (33, 34):
fail("desktop schema v33-v34 required")
db.execute("BEGIN IMMEDIATE")
apply(db, root)
db.commit()

View file

@ -0,0 +1,400 @@
#!/usr/bin/env python3
"""Validate/import Android-owned guided-cardio control history."""
import argparse
import json
import re
import sqlite3
from datetime import datetime
from pathlib import Path
from trainlog_sqlite import connect_database
FORMAT = "trainlog-cardio-guidance"
VERSION = 1
MAX_BYTES = 8 * 1024 * 1024
MAX_RUNS = 128
MAX_PHASES = 128
MAX_EVENTS = 4096
UUID4 = r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}"
RUN_ID = re.compile(rf"^cgr_{UUID4}$")
PHASE_ID = re.compile(rf"^cgp_{UUID4}$")
SESSION_ID = re.compile(rf"^se_{UUID4}$")
ENTRY_ID = re.compile(rf"^sxe_{UUID4}$")
CALIBRATION_ID = re.compile(rf"^cal_{UUID4}$")
KINDS = {"warmup", "work", "recovery", "cooldown"}
INSTRUCTIONS = {"accelerate", "maintain", "slow_down", "suspended"}
EXIT_KINDS = {"fixed_duration", "enter_target", "recover_below", "duration_or_recover"}
class CardioGuidanceError(ValueError):
pass
def fail(message: str) -> None:
raise CardioGuidanceError(message)
def unique_object(pairs):
result = {}
for key, value in pairs:
if key in result:
fail("duplicate cardio guidance 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 guidance timestamp")
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError as error:
raise CardioGuidanceError("invalid cardio guidance timestamp") from error
if parsed.utcoffset() is None:
fail("cardio guidance timestamp lacks offset")
return value, parsed
def _target(value: object) -> dict | None:
if value is None:
return None
keys = {
"minimum_bpm", "maximum_bpm", "calibration_id",
"calibration_observed_peak_bpm", "minimum_percent", "maximum_percent",
}
if not isinstance(value, dict) or set(value) != keys:
fail("invalid cardio guidance target")
minimum = value["minimum_bpm"]
maximum = value["maximum_bpm"]
if type(minimum) is not int or type(maximum) is not int or not 1 <= minimum <= maximum <= 65535:
fail("invalid cardio guidance BPM target")
provenance = (
value["calibration_id"],
value["calibration_observed_peak_bpm"],
value["minimum_percent"],
value["maximum_percent"],
)
if all(item is None for item in provenance):
return value
if any(item is None for item in provenance):
fail("incomplete cardio calibration target provenance")
calibration_id, peak, min_percent, max_percent = provenance
if not isinstance(calibration_id, str) or not CALIBRATION_ID.fullmatch(calibration_id):
fail("invalid cardio calibration target identity")
if (
type(peak) is not int or not 1 <= peak <= 65535
or type(min_percent) is not int or type(max_percent) is not int
or not 1 <= min_percent <= max_percent <= 100
):
fail("invalid cardio calibration target provenance")
return value
def _exit(value: object, target: dict | None) -> dict:
if not isinstance(value, dict) or set(value) != {"kind", "seconds", "bpm"}:
fail("invalid cardio phase exit condition")
kind = value["kind"]
seconds = value["seconds"]
bpm = value["bpm"]
if kind not in EXIT_KINDS:
fail("invalid cardio phase exit kind")
if kind == "fixed_duration":
if type(seconds) is not int or not 1 <= seconds <= 86400 or bpm is not None:
fail("invalid fixed cardio duration")
elif kind == "enter_target":
if target is None or seconds is not None or bpm is not None:
fail("invalid enter-target condition")
elif kind == "recover_below":
if seconds is not None or type(bpm) is not int or not 1 <= bpm <= 65535:
fail("invalid cardio recovery threshold")
else:
if (
type(seconds) is not int or not 1 <= seconds <= 86400
or type(bpm) is not int or not 1 <= bpm <= 65535
):
fail("invalid bounded cardio recovery")
return value
def validate(root: object) -> dict:
if not isinstance(root, dict) or set(root) != {"format", "version", "generated_at", "runs"}:
fail("invalid cardio guidance envelope")
if root["format"] != FORMAT or type(root["version"]) is not int or root["version"] != VERSION:
fail("unsupported cardio guidance format")
timestamp(root["generated_at"])
runs = root["runs"]
if not isinstance(runs, list) or len(runs) > MAX_RUNS:
fail("cardio guidance run bound exceeded")
seen_runs = set()
seen_phases = set()
for run in runs:
if not isinstance(run, dict) or set(run) != {
"run_id", "session_id", "started_at", "ended_at", "phases"
}:
fail("invalid cardio guidance run")
if (
not isinstance(run["run_id"], str) or not RUN_ID.fullmatch(run["run_id"])
or run["run_id"] in seen_runs
):
fail("invalid or duplicate cardio guidance run identity")
seen_runs.add(run["run_id"])
if not isinstance(run["session_id"], str) or not SESSION_ID.fullmatch(run["session_id"]):
fail("invalid cardio guidance session identity")
_, run_start = timestamp(run["started_at"])
_, run_end = timestamp(run["ended_at"])
if run_end < run_start:
fail("invalid cardio guidance run interval")
phases = run["phases"]
if not isinstance(phases, list) or not phases or len(phases) > MAX_PHASES:
fail("invalid cardio guidance phases")
positions = []
for phase in phases:
keys = {
"phase_id", "entry_id", "position", "kind", "target",
"exit_condition", "started_at", "ended_at", "final_instruction", "events",
}
if not isinstance(phase, dict) or set(phase) != keys:
fail("invalid cardio guidance phase")
if (
not isinstance(phase["phase_id"], str) or not PHASE_ID.fullmatch(phase["phase_id"])
or phase["phase_id"] in seen_phases
):
fail("invalid or duplicate cardio guidance phase identity")
seen_phases.add(phase["phase_id"])
if not isinstance(phase["entry_id"], str) or not ENTRY_ID.fullmatch(phase["entry_id"]):
fail("invalid cardio guidance entry identity")
if type(phase["position"]) is not int or phase["position"] < 0 or phase["position"] in positions:
fail("invalid cardio guidance phase position")
positions.append(phase["position"])
if phase["kind"] not in KINDS:
fail("invalid cardio phase kind")
target = _target(phase["target"])
_exit(phase["exit_condition"], target)
_, phase_start = timestamp(phase["started_at"])
_, phase_end = timestamp(phase["ended_at"])
if not run_start <= phase_start <= phase_end <= run_end:
fail("cardio phase outside run")
if phase["final_instruction"] not in INSTRUCTIONS:
fail("invalid final cardio instruction")
events = phase["events"]
if not isinstance(events, list) or len(events) > MAX_EVENTS:
fail("cardio guidance event bound exceeded")
for expected, event in enumerate(events):
if not isinstance(event, dict) or set(event) != {
"sequence", "observed_at", "instruction", "bpm",
"target_minimum_bpm", "target_maximum_bpm",
}:
fail("invalid cardio guidance event")
if type(event["sequence"]) is not int or event["sequence"] != expected:
fail("invalid cardio guidance event sequence")
_, observed = timestamp(event["observed_at"])
if not phase_start <= observed <= phase_end:
fail("cardio guidance event outside phase")
instruction = event["instruction"]
if instruction not in INSTRUCTIONS:
fail("invalid cardio guidance instruction")
bpm = event["bpm"]
if instruction == "suspended":
if bpm is not None:
fail("suspended guidance cannot contain BPM")
elif type(bpm) is not int or not 1 <= bpm <= 65535:
fail("active guidance requires BPM")
target_min = event["target_minimum_bpm"]
target_max = event["target_maximum_bpm"]
if target is None:
if target_min is not None or target_max is not None:
fail("guidance event unexpectedly carries target")
elif target_min != target["minimum_bpm"] or target_max != target["maximum_bpm"]:
fail("guidance event target differs from phase snapshot")
if positions != sorted(positions):
fail("cardio guidance phases must be ordered")
return root
def load(path: Path) -> dict:
if path.stat().st_size > MAX_BYTES:
fail("cardio guidance artifact exceeds 8 MiB")
return validate(json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=unique_object))
def _verify_source_facts(db: sqlite3.Connection, run: dict) -> None:
session = db.execute(
"SELECT id,started_at,ended_at,session_kind FROM sessions WHERE session_id=?",
(run["session_id"],),
).fetchone()
if session is None or session[3] != "cardio":
fail("guided session is missing or not cardio")
_, session_start = timestamp(session[1])
_, session_end = timestamp(session[2])
_, run_start = timestamp(run["started_at"])
_, run_end = timestamp(run["ended_at"])
if not session_start <= run_start <= run_end <= session_end:
fail("guided run outside canonical cardio session")
for phase in run["phases"]:
occurrence = db.execute(
"SELECT 1 FROM session_exercises se WHERE se.session_row_id=? AND se.entry_id=?",
(session[0], phase["entry_id"]),
).fetchone()
if occurrence is None:
fail("guided phase occurrence is missing")
timing = db.execute(
"SELECT started_at,ended_at FROM session_exercise_timeline "
"WHERE session_id=? AND entry_id=?",
(run["session_id"], phase["entry_id"]),
).fetchone()
if timing is None:
fail("guided phase exercise timeline is missing")
_, exercise_start = timestamp(timing[0])
_, exercise_end = timestamp(timing[1])
_, phase_start = timestamp(phase["started_at"])
_, phase_end = timestamp(phase["ended_at"])
if not exercise_start <= phase_start <= phase_end <= exercise_end:
fail("guided phase outside exercise timeline")
target = phase["target"]
if target is not None and target["calibration_id"] is not None:
calibration = db.execute(
"SELECT observed_peak_bpm FROM cardio_calibrations WHERE calibration_id=?",
(target["calibration_id"],),
).fetchone()
if calibration is None or calibration[0] != target["calibration_observed_peak_bpm"]:
fail("guided phase calibration provenance is unavailable")
for event in phase["events"]:
if event["bpm"] is None:
continue
exact = db.execute(
"SELECT 1 FROM heart_rate_samples s JOIN heart_rate_captures c "
"ON c.capture_id=s.capture_id "
"WHERE c.context_kind='cardio' AND c.context_id=? "
"AND s.observed_at=? AND s.bpm=? LIMIT 1",
(run["session_id"], event["observed_at"], event["bpm"]),
).fetchone()
if exact is None:
fail("guided instruction BPM is not a raw heart-rate sample")
def _same(db: sqlite3.Connection, run: dict) -> bool:
row = db.execute(
"SELECT session_id,started_at,ended_at FROM cardio_guidance_runs WHERE run_id=?",
(run["run_id"],),
).fetchone()
if row is None:
return False
if tuple(row) != (run["session_id"], run["started_at"], run["ended_at"]):
fail("cardio guidance run identity reused with different metadata")
stored_phases = db.execute(
"SELECT phase_id,entry_id,position,kind,target_min_bpm,target_max_bpm,"
"calibration_id,calibration_observed_peak_bpm,minimum_percent,maximum_percent,"
"exit_kind,exit_seconds,exit_bpm,started_at,ended_at,final_instruction "
"FROM cardio_guidance_phases WHERE run_id=? ORDER BY position,phase_id",
(run["run_id"],),
).fetchall()
if len(stored_phases) != len(run["phases"]):
fail("cardio guidance run identity reused with different phases")
for stored, phase in zip(stored_phases, run["phases"]):
target = phase["target"]
exit_condition = phase["exit_condition"]
expected = (
phase["phase_id"], phase["entry_id"], phase["position"], phase["kind"],
None if target is None else target["minimum_bpm"],
None if target is None else target["maximum_bpm"],
None if target is None else target["calibration_id"],
None if target is None else target["calibration_observed_peak_bpm"],
None if target is None else target["minimum_percent"],
None if target is None else target["maximum_percent"],
exit_condition["kind"], exit_condition["seconds"], exit_condition["bpm"],
phase["started_at"], phase["ended_at"], phase["final_instruction"],
)
if tuple(stored) != expected:
fail("cardio guidance phase identity reused with different content")
stored_events = [
tuple(row)
for row in db.execute(
"SELECT sequence,observed_at,instruction,bpm,target_min_bpm,target_max_bpm "
"FROM cardio_guidance_events WHERE run_id=? AND phase_id=? ORDER BY sequence",
(run["run_id"], phase["phase_id"]),
)
]
incoming = [
(
event["sequence"], event["observed_at"], event["instruction"], event["bpm"],
event["target_minimum_bpm"], event["target_maximum_bpm"],
)
for event in phase["events"]
]
if stored_events != incoming:
fail("cardio guidance phase identity reused with different events")
return True
def apply(db: sqlite3.Connection, root: dict) -> tuple[int, int]:
applied = unchanged = 0
for run in root["runs"]:
_verify_source_facts(db, run)
if _same(db, run):
unchanged += 1
continue
db.execute(
"INSERT INTO cardio_guidance_runs(run_id,session_id,started_at,ended_at,imported_at) "
"VALUES(?,?,?,?,?)",
(run["run_id"], run["session_id"], run["started_at"], run["ended_at"], root["generated_at"]),
)
for phase in run["phases"]:
target = phase["target"]
exit_condition = phase["exit_condition"]
db.execute(
"INSERT INTO cardio_guidance_phases("
"run_id,phase_id,entry_id,position,kind,target_min_bpm,target_max_bpm,"
"calibration_id,calibration_observed_peak_bpm,minimum_percent,maximum_percent,"
"exit_kind,exit_seconds,exit_bpm,started_at,ended_at,final_instruction"
") VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
(
run["run_id"], phase["phase_id"], phase["entry_id"], phase["position"],
phase["kind"],
None if target is None else target["minimum_bpm"],
None if target is None else target["maximum_bpm"],
None if target is None else target["calibration_id"],
None if target is None else target["calibration_observed_peak_bpm"],
None if target is None else target["minimum_percent"],
None if target is None else target["maximum_percent"],
exit_condition["kind"], exit_condition["seconds"], exit_condition["bpm"],
phase["started_at"], phase["ended_at"], phase["final_instruction"],
),
)
db.executemany(
"INSERT INTO cardio_guidance_events("
"run_id,phase_id,sequence,observed_at,instruction,bpm,target_min_bpm,target_max_bpm"
") VALUES(?,?,?,?,?,?,?,?)",
[
(
run["run_id"], phase["phase_id"], event["sequence"], event["observed_at"],
event["instruction"], event["bpm"], event["target_minimum_bpm"],
event["target_maximum_bpm"],
)
for event in phase["events"]
],
)
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] != 34:
fail("desktop schema v34 required")
db.execute("BEGIN IMMEDIATE")
apply(db, root)
db.commit()
if __name__ == "__main__":
main()

View file

@ -38,8 +38,8 @@ def digest(value) -> str:
def require_schema(db: sqlite3.Connection) -> None:
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, 33):
raise CausalError(f"desktop schema v20-v33 required, found v{version}")
if version not in (20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34):
raise CausalError(f"desktop schema v20-v34 required, found v{version}")
def target_snapshot(db: sqlite3.Connection, kind: str, target: str):

View file

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

View file

@ -12,7 +12,7 @@ from pathlib import Path
from trainlog_sqlite import configure_connection
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33)
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34)
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-v33 requis")
raise ValueError("schema desktop v15-v34 requis")
root = {
"format": "TRAINLOG_AI_EXPORT",

View file

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

View file

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

View file

@ -24,9 +24,9 @@ def main(complete_causal_envelope=False):
connection = connect_database(args.database)
connection.row_factory = sqlite3.Row
try:
supported_versions = tuple(range(8, 34))
supported_versions = tuple(range(8, 35))
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v8 à v33 requis")
raise ValueError("schema desktop v8 à v34 requis")
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone():
raise ValueError("causal equipment protection requires the staged artifact")
equipment = [dict(row) for row in connection.execute(

View file

@ -17,8 +17,8 @@ def main():
con = connect_database(args.database)
try:
if con.execute("PRAGMA user_version").fetchone()[0] not in (
12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33):
raise ValueError("schema desktop v12-v33 requis")
12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34):
raise ValueError("schema desktop v12-v34 requis")
rows = con.execute(
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "
"ORDER BY source_exercise_id COLLATE BINARY"

View file

@ -41,9 +41,9 @@ def main(complete_causal_envelope=False):
connection = connect_database(args.database)
connection.row_factory = sqlite3.Row
try:
supported_versions = tuple(range(11, 34))
supported_versions = tuple(range(11, 35))
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v11-v33 requis")
raise ValueError("schema desktop v11-v34 requis")
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone():
raise ValueError("causal BODY ZONES protection requires the staged artifact")
exercises = []

View file

@ -25,7 +25,7 @@ def main():
con.row_factory = sqlite3.Row
try:
version = con.execute("PRAGMA user_version").fetchone()[0]
if version not in (17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33):
if version not in (17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34):
raise SystemExit(f"EXERCISE_PROFILE_STATE_EXPORT=FAIL schema={version}")
rows = con.execute("""
SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields,

View file

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

View file

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

View file

@ -16,7 +16,7 @@ def nullable(row, index):
def export_programs(database: Path) -> dict:
with closing(connect_database(database)) as db:
if db.execute("PRAGMA user_version").fetchone()[0] not in (26, 27, 28, 29, 30, 31, 32, 33):
if db.execute("PRAGMA user_version").fetchone()[0] not in (26, 27, 28, 29, 30, 31, 32, 33, 34):
raise ValueError("desktop schema v26 through v32 required")
programs = []
for row in db.execute(

View file

@ -17,7 +17,7 @@ MAX_WITHDRAWALS = 128
def build_export(connection: sqlite3.Connection) -> dict:
supported_versions = (24, 25, 26, 27, 28, 29, 30, 31, 32, 33)
supported_versions = (24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("desktop schema v24, v25, v26 through v32 required")
deliveries = []

View file

@ -10,9 +10,9 @@ def main(complete_causal_envelope=False):
parser=argparse.ArgumentParser(); parser.add_argument("output",type=Path); parser.add_argument("--database",type=Path,required=True); args=parser.parse_args()
db=connect_database(args.database)
try:
supported_versions = range(15, 34)
supported_versions = range(15, 35)
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v15-v33 requis")
raise ValueError("schema desktop v15-v34 requis")
if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0] >= 20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone(): raise ValueError("causal feedback protection requires the staged artifact")
roots=db.execute("SELECT f.feedback_id,s.session_id,se.entry_id,e.exercise_id,f.observed_at FROM exercise_feedback f JOIN session_exercises se ON se.id=f.session_exercise_row_id JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id").fetchall()
followups=db.execute("SELECT f.followup_id,s.session_id,f.observed_at FROM session_followups f JOIN sessions s ON s.id=f.session_row_id").fetchall()

View file

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

View file

@ -193,9 +193,9 @@ def import_payload(connection, payload, imported_at=None):
INVARIANT: validation and every child insert share one IMMEDIATE transaction;
any failure leaves no draft, child, or import-identity row behind.
"""
supported_versions = range(18, 34)
supported_versions = range(18, 35)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ImportFailure("schema desktop v18-v33 requis")
raise ImportFailure("schema desktop v18-v34 requis")
draft, entries = validate(payload, connection)
digest = canonical_digest(payload)
imported_at = imported_at or dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")

View file

@ -206,9 +206,9 @@ def main():
payload = json.loads(args.artifact.read_text(encoding="utf-8"))
connection = connect_database(args.database)
try:
supported_versions = range(8, 34)
supported_versions = range(8, 35)
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
fail("schema desktop v8 à v33 requis")
fail("schema desktop v8 à v34 requis")
proof_path = args.mobile_export
if proof_path is None:
candidate = args.artifact.with_name("trainlog-mobile-export-v2.json")

View file

@ -53,9 +53,9 @@ def validate(payload, reserved):
def apply_definitions(connection, definitions, complete_causal_envelope=False):
"""Apply validated definitions without owning the surrounding transaction."""
supported_versions = range(8, 34)
supported_versions = range(8, 35)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
fail("schema desktop v8 à v33 requis")
fail("schema desktop v8 à v34 requis")
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone():
fail("causal equipment protection requires the staged artifact")
imported = skipped = 0

View file

@ -56,9 +56,9 @@ def load(path):
def apply_aliases(con, aliases):
"""Apply parsed aliases inside the caller-owned transaction."""
supported_versions = range(12, 34)
supported_versions = range(12, 35)
if con.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
fail("schema desktop v12-v33 requis")
fail("schema desktop v12-v34 requis")
for source, canonical in aliases:
target = con.execute("SELECT id,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?", (canonical,)).fetchone()
if target is None: fail(f"cible canonique absente: {canonical}")

View file

@ -151,9 +151,9 @@ def replace(connection, row_id, primary, secondary):
def apply_body_zones(connection, parsed, mobile_proof, complete_causal_envelope=False):
"""Apply validated zone rows without owning transaction boundaries."""
supported_versions = range(11, 34)
supported_versions = range(11, 35)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ImportFailure("schema desktop v11-v33 requis")
raise ImportFailure("schema desktop v11-v34 requis")
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0]>=20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone(): raise ImportFailure("causal BODY ZONES protection requires the staged artifact")
connection.execute("PRAGMA foreign_keys=ON");grouped={}
# CONTRACT: resolve complete alias groups before the first write; direct

View file

@ -50,8 +50,8 @@ def apply_profile_state(con, root, allow_pending=False):
if not isinstance(root,dict) or set(root)!={"format","version","generated_at","exercises"} or root["format"]!="trainlog-exercise-profile-state" or type(root["version"]) is not int or root["version"]!=1 or not isinstance(root["exercises"],list): fail("enveloppe profile-state invalide")
parse_timestamp(root["generated_at"], "generated_at");con.row_factory=sqlite3.Row
if con.execute("PRAGMA user_version").fetchone()[0] not in (
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33):
fail("schema desktop v17-v33 requis")
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34):
fail("schema desktop v17-v34 requis")
applied=equal=ancestor=pending=0;seen=set()
for item in root["exercises"]:
validate(item);eid=item["exercise_id"]

View file

@ -847,9 +847,9 @@ def require_supported_schema(connection):
# CONTRACT: v9 owns explicit max_results; earlier supported schemas remain
# readable for legacy artifacts and are never made to fake that table.
if version not in range(5, 34):
if version not in range(5, 35):
raise ImportFailure(
f"base desktop schema v5 à v33 attendue, version trouvée: {version}"
f"base desktop schema v5 à v34 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, 33):
raise ImportFailure("mobile V4 exige le schéma desktop v19 à v33")
if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34):
raise ImportFailure("mobile V4 exige le schéma desktop v19 à v34")
if schema_version >= 20:
protected = {(row[0], row[1]) for row in connection.execute(
"SELECT target_kind,target_id FROM sync_causal_state WHERE deleted=1")}

View file

@ -52,9 +52,9 @@ def merge_revisions(db,table,parent_column,parent_id,revisions):
def apply_feedback(db,root,complete_causal_envelope=False):
roots_added=roots_skipped=revisions_added=revisions_skipped=0
db.execute("PRAGMA foreign_keys=ON")
supported_versions = range(15, 34)
supported_versions = range(15, 35)
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
fail("schema desktop v15-v33 requis")
fail("schema desktop v15-v34 requis")
if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0]>=20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone():fail("causal feedback protection requires the staged artifact")
seen=set()
for index,item in enumerate(root["exercise_feedback"]):

View file

@ -20,6 +20,7 @@ RUNTIME_TOOLS = [
"sleep_diary_exchange.py",
"cardio_session_exchange.py",
"cardio_calibration_exchange.py",
"cardio_guidance_exchange.py",
"heart_rate_exchange.py",
"session_timeline_exchange.py",
"trainlog_sqlite.py",
@ -118,8 +119,8 @@ def main() -> int:
["git", "rev-parse", "HEAD"], cwd=ROOT, text=True
).strip(),
"product_version": "0.1.5",
"desktop_schema": 33,
"android_schema": 30,
"desktop_schema": 34,
"android_schema": 31,
"protocols": [
"mobile-export-v3",
"mobile-history-v4",
@ -136,6 +137,7 @@ def main() -> int:
"sleep-diary-v1",
"cardio-sessions-v1",
"cardio-calibrations-v1",
"cardio-guidance-v1",
"heart-rate-v1",
"session-timeline-v1",
],

View file

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

View file

@ -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, 33):
fail("desktop schema v31-v33 required")
if db.execute("PRAGMA user_version").fetchone()[0] not in (31, 32, 33, 34):
fail("desktop schema v31-v34 required")
db.execute("BEGIN IMMEDIATE")
apply(db, root)
db.commit()

View file

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

View file

@ -22,6 +22,7 @@ from trainlog_sqlite import connect_database
import causal_delete_exchange
import cardio_session_exchange
import cardio_calibration_exchange
import cardio_guidance_exchange
import execution_draft_exchange
import heart_rate_exchange
import session_timeline_exchange
@ -87,6 +88,7 @@ SUPPORTED = {(row[1], row[2]) for row in ARTIFACTS} | {
("trainlog-program-executions", 1),
("trainlog-cardio-sessions", 1),
("trainlog-cardio-calibrations", 1),
("trainlog-cardio-guidance", 1),
("trainlog-heart-rate", 1),
("trainlog-session-timeline", 1),
}
@ -217,9 +219,9 @@ def validate_manifest_bytes(raw: bytes, expected_consumer: str | None = None):
def require_schema(db: sqlite3.Connection) -> None:
supported_versions = (24, 25, 26, 27, 28, 29, 30, 31, 32, 33)
supported_versions = (24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34)
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise GenerationError("desktop schema v24, v25, v26 through v32 required")
raise GenerationError("desktop schema v24 through v34 required")
def peer_identity(db: sqlite3.Connection, kind: str) -> str:
@ -687,6 +689,10 @@ def consume_desktop(database: Path, directory: Path) -> dict:
cardio_calibration_exchange.load(listed["cardio-calibrations"])
if "cardio-calibrations" in listed else None
)
cardio_guidance = (
cardio_guidance_exchange.load(listed["cardio-guidance"])
if "cardio-guidance" in listed else None
)
session_timeline = (session_timeline_exchange.load(listed["session-timeline"])
if "session-timeline" in listed else None)
program_executions = (
@ -744,6 +750,8 @@ def consume_desktop(database: Path, directory: Path) -> dict:
cardio_calibration_exchange.apply(db, cardio_calibrations)
if session_timeline is not None:
session_timeline_exchange.apply(db, session_timeline)
if cardio_guidance is not None:
cardio_guidance_exchange.apply(db, cardio_guidance)
if program_executions is not None:
program_execution_exchange.apply_executions(db, program_executions)
ack = record_consumed(db, manifest, manifest_digest)

View file

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

View file

@ -855,6 +855,14 @@ test_schema_v33_migration = executable(
)
test('schema_v33_migration', test_schema_v33_migration)
test_schema_v34_migration = executable(
'test_schema_v34_migration',
'tests/test_schema_v34_migration.c',
dependencies: trainlog_core_dep,
c_args: strict_c_args,
)
test('schema_v34_migration', test_schema_v34_migration)
test_schema_v7_migration = executable(
'test_schema_v7_migration',
'tests/test_schema_v7_migration.c',
@ -954,6 +962,11 @@ test(
find_program('python3'),
args: [meson.project_source_root() / 'tests/test_cardio_calibration_exchange.py'],
)
test(
'cardio_guidance_exchange',
find_program('python3'),
args: [meson.project_source_root() / 'tests/test_cardio_guidance_exchange.py'],
)
test(
'session_preparation_export',
find_program('python3'),

View file

@ -1426,6 +1426,53 @@ static const char *const MIGRATE_V32_TO_V33_SQL =
"cardio_calibrations(session_id,ended_at,calibration_id);"
"PRAGMA user_version=33;COMMIT;";
/* WHY: guided cardio is derived control history, distinct from raw HR.
* CONTRACT: v34 stores imported completed guidance runs, exact target snapshots
* and instruction changes. INVARIANT: no BPM, phase, calibration or session is
* inferred or rewritten by migration. */
static const char *const MIGRATE_V33_TO_V34_SQL =
"BEGIN IMMEDIATE;"
"CREATE TABLE IF NOT EXISTS cardio_guidance_runs("
"run_id TEXT PRIMARY KEY,session_id TEXT NOT NULL REFERENCES sessions(session_id) ON DELETE RESTRICT,"
"started_at TEXT NOT NULL,ended_at TEXT NOT NULL,imported_at TEXT NOT NULL,"
"CHECK(run_id GLOB 'cgr_*'),CHECK(session_id GLOB 'se_*'));"
"CREATE TABLE IF NOT EXISTS cardio_guidance_phases("
"run_id TEXT NOT NULL REFERENCES cardio_guidance_runs(run_id) ON DELETE CASCADE,"
"phase_id TEXT NOT NULL UNIQUE,entry_id TEXT NOT NULL,position INTEGER NOT NULL CHECK(position>=0),"
"kind TEXT NOT NULL CHECK(kind IN('warmup','work','recovery','cooldown')),"
"target_min_bpm INTEGER,target_max_bpm INTEGER,calibration_id TEXT,"
"calibration_observed_peak_bpm INTEGER,minimum_percent INTEGER,maximum_percent INTEGER,"
"exit_kind TEXT NOT NULL CHECK(exit_kind IN('fixed_duration','enter_target','recover_below','duration_or_recover')),"
"exit_seconds INTEGER,exit_bpm INTEGER,started_at TEXT NOT NULL,ended_at TEXT NOT NULL,"
"final_instruction TEXT NOT NULL CHECK(final_instruction IN('accelerate','maintain','slow_down','suspended')),"
"PRIMARY KEY(run_id,phase_id),UNIQUE(run_id,position),CHECK(phase_id GLOB 'cgp_*'),"
"CHECK(entry_id GLOB 'sxe_*'),"
"CHECK((target_min_bpm IS NULL AND target_max_bpm IS NULL) OR "
"(target_min_bpm BETWEEN 1 AND 65535 AND target_max_bpm BETWEEN target_min_bpm AND 65535)),"
"CHECK((calibration_id IS NULL AND calibration_observed_peak_bpm IS NULL AND "
"minimum_percent IS NULL AND maximum_percent IS NULL) OR "
"(calibration_id GLOB 'cal_*' AND calibration_observed_peak_bpm BETWEEN 1 AND 65535 AND "
"minimum_percent BETWEEN 1 AND 100 AND maximum_percent BETWEEN minimum_percent AND 100)),"
"CHECK((exit_kind='fixed_duration' AND exit_seconds>0 AND exit_bpm IS NULL) OR "
"(exit_kind='enter_target' AND exit_seconds IS NULL AND exit_bpm IS NULL AND target_min_bpm IS NOT NULL) OR "
"(exit_kind='recover_below' AND exit_seconds IS NULL AND exit_bpm BETWEEN 1 AND 65535) OR "
"(exit_kind='duration_or_recover' AND exit_seconds>0 AND exit_bpm BETWEEN 1 AND 65535)));"
"CREATE TABLE IF NOT EXISTS cardio_guidance_events("
"run_id TEXT NOT NULL,phase_id TEXT NOT NULL,sequence INTEGER NOT NULL CHECK(sequence>=0),"
"observed_at TEXT NOT NULL,instruction TEXT NOT NULL "
"CHECK(instruction IN('accelerate','maintain','slow_down','suspended')),"
"bpm INTEGER CHECK(bpm IS NULL OR bpm BETWEEN 1 AND 65535),"
"target_min_bpm INTEGER,target_max_bpm INTEGER,"
"PRIMARY KEY(run_id,phase_id,sequence),"
"FOREIGN KEY(run_id,phase_id) REFERENCES cardio_guidance_phases(run_id,phase_id) ON DELETE CASCADE,"
"CHECK((target_min_bpm IS NULL AND target_max_bpm IS NULL) OR "
"(target_min_bpm BETWEEN 1 AND 65535 AND target_max_bpm BETWEEN target_min_bpm AND 65535)));"
"CREATE INDEX IF NOT EXISTS cardio_guidance_session ON "
"cardio_guidance_runs(session_id,started_at,run_id);"
"CREATE INDEX IF NOT EXISTS cardio_guidance_event_time ON "
"cardio_guidance_events(observed_at,run_id,phase_id,sequence);"
"PRAGMA user_version=34;COMMIT;";
static TrainlogStatus ensure_v32_session_kind(TrainlogDatabase *database) {
sqlite3_stmt *statement = NULL;
bool found_kind = false;
@ -2295,7 +2342,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 == 33) {
version == 31 || version == 32 || version == 33 || version == 34) {
status = TRAINLOG_STATUS_OK;
} else {
if (version == 1) {
@ -2458,6 +2505,9 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
if (status == TRAINLOG_STATUS_OK && version < 33) {
status = execute_sql(database, MIGRATE_V32_TO_V33_SQL);
}
if (status == TRAINLOG_STATUS_OK && version < 34) {
status = execute_sql(database, MIGRATE_V33_TO_V34_SQL);
}
if (status == TRAINLOG_STATUS_OK) {
status = ensure_v18_ai_draft_publication_state(database);
}
@ -2477,7 +2527,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 v33" : "migrate database to schema v33",
version == 0 ? "create schema v34" : "migrate database to schema v34",
database->connection,
SQLITE_ERROR);
(void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL);

View file

@ -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") == 33);
CHECK(scalar(raw, "PRAGMA user_version") == 34);
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") == 33);
CHECK(scalar(raw, "PRAGMA user_version") == 34);
CHECK(scalar(raw,
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name LIKE "
"'sleep_medication%'") == 3);

View file

@ -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 == 33);
CHECK(version == 34);
trainlog_database_close(production);
production = NULL;

View file

@ -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 == 33);
CHECK(version == 34);
trainlog_database_close(production);
production = NULL;

View file

@ -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 == 33);
CHECK(version == 34);
trainlog_database_close(production);
production = NULL;

View file

@ -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 == 33);
CHECK(version == 34);
trainlog_database_close(production);
production = NULL;

View file

@ -0,0 +1,69 @@
#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-v34-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_guidance_events;"
"DROP TABLE cardio_guidance_phases;"
"DROP TABLE cardio_guidance_runs;"
"PRAGMA user_version=33;",
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 == 34);
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_guidance_runs','cardio_guidance_phases','cardio_guidance_events')") == 3);
CHECK(scalar(raw,
"SELECT COUNT(*) FROM sqlite_master WHERE type='index' "
"AND name IN('cardio_guidance_session','cardio_guidance_event_time')") == 2);
CHECK(scalar(raw, "SELECT COUNT(*) FROM cardio_guidance_runs") == 0);
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 v33 to v34 cardio guidance migration");
return result;
}

View file

@ -178,8 +178,8 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void) {
TRAINLOG_STATUS_DATABASE_ERROR);
CHECK(database == NULL);
CHECK(strncmp(diagnostic,
"migrate database to schema v33: SQLite rc=",
strlen("migrate database to schema v33: SQLite rc=")) == 0);
"migrate database to schema v34: SQLite rc=",
strlen("migrate database to schema v34: 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=34;", NULL, NULL, NULL) == SQLITE_OK);
CHECK(sqlite3_exec(raw, "PRAGMA user_version=35;", 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 34 is newer") != NULL);
CHECK(strstr(diagnostic, "schema version 35 is newer") != NULL);
CHECK(strstr(diagnostic, "SQLite") == NULL);
CHECK(unlink(path) == 0);
return true;

View file

@ -154,7 +154,7 @@ static bool test_v9_to_v10_failure_rolls_back(void) {
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) ==
TRAINLOG_STATUS_DATABASE_ERROR);
CHECK(database == NULL);
CHECK(strstr(diagnostic, "migrate database to schema v33") != NULL);
CHECK(strstr(diagnostic, "migrate database to schema v34") != NULL);
CHECK(strstr(diagnostic, "performed_sets_v9") != NULL);
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);