feat(cardio): guide phases from measured heart rate
This commit is contained in:
parent
8371d5e16c
commit
ad493a72aa
80 changed files with 3385 additions and 105 deletions
13
CHANGELOG.md
13
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(" +
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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") }
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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' " +
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
236
tests/test_cardio_guidance_exchange.py
Normal file
236
tests/test_cardio_guidance_exchange.py
Normal 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()
|
||||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
400
tools/cardio_guidance_exchange.py
Normal file
400
tools/cardio_guidance_exchange.py
Normal 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()
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
|
|
|
|||
|
|
@ -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"]):
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
69
tui/tests/test_schema_v34_migration.c
Normal file
69
tui/tests/test_schema_v34_migration.c
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue