diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d321fc..dac0460 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/README.md b/README.md index 4812e0f..bfca54c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/AndroidBackupService.kt b/android/app/src/main/java/com/labfytools/trainlog/data/AndroidBackupService.kt index db5db06..4a587e6 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/AndroidBackupService.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/AndroidBackupService.kt @@ -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() diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/CardioGuidanceEngine.kt b/android/app/src/main/java/com/labfytools/trainlog/data/CardioGuidanceEngine.kt new file mode 100644 index 0000000..65b5ba5 --- /dev/null +++ b/android/app/src/main/java/com/labfytools/trainlog/data/CardioGuidanceEngine.kt @@ -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 + } +} diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/CardioGuidanceLiveState.kt b/android/app/src/main/java/com/labfytools/trainlog/data/CardioGuidanceLiveState.kt new file mode 100644 index 0000000..ac3c492 --- /dev/null +++ b/android/app/src/main/java/com/labfytools/trainlog/data/CardioGuidanceLiveState.kt @@ -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 } + } +} diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/HeartRateSensorService.kt b/android/app/src/main/java/com/labfytools/trainlog/data/HeartRateSensorService.kt index 5f3c909..9f9b71b 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/HeartRateSensorService.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/HeartRateSensorService.kt @@ -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() diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationService.kt b/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationService.kt index ac71c8f..576c0d9 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationService.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationService.kt @@ -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(" + diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt index d205b49..1ca2122 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt @@ -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, + ): 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 { + if (!sleepIdentity(phaseId, "cgp")) return emptyList() + val phase = + readCardioGuidancePhase( + database.readableDatabase, + "p.phase_id=?", + arrayOf(phaseId), + ) ?: return emptyList() + val output = mutableListOf() + 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( + 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( + 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( diff --git a/android/app/src/main/java/com/labfytools/trainlog/model/CardioGuidanceModels.kt b/android/app/src/main/java/com/labfytools/trainlog/model/CardioGuidanceModels.kt new file mode 100644 index 0000000..2edeef9 --- /dev/null +++ b/android/app/src/main/java/com/labfytools/trainlog/model/CardioGuidanceModels.kt @@ -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, +) diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/CardioGuidancePanel.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/CardioGuidancePanel.kt new file mode 100644 index 0000000..2b26158 --- /dev/null +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/CardioGuidancePanel.kt @@ -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(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) diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt index e1d57c4..5e4f0bd 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt @@ -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), diff --git a/android/app/src/main/res/values-en/strings.xml b/android/app/src/main/res/values-en/strings.xml index 3d1342d..8dafeed 100644 --- a/android/app/src/main/res/values-en/strings.xml +++ b/android/app/src/main/res/values-en/strings.xml @@ -585,4 +585,36 @@ Close recovery and save the calibration profile. Stop calibration Another session or calibration is already active. + Cardio guidance + Phase: %1$s + Target: %1$d–%2$d BPM + Calibration: observed peak %1$d BPM · %2$d–%3$d %% + Latest calibration: observed peak %1$d BPM + Resolved target: %1$d–%2$d BPM + SPEED UP + MAINTAIN + SLOW DOWN + HEART-RATE SIGNAL UNAVAILABLE + %1$d BPM + -- BPM + Start phase + End phase + Complete the target and exit condition. + Unable to start this phase. + Warm-up + Work + Recovery + Cool-down + BPM + Calibration %% + Min BPM + Max BPM + Min %% + Max %% + Duration + Enter target + Below threshold + Duration or threshold + Max duration + Recovery threshold diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 00b6c40..edebd61 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -595,4 +595,36 @@ Fermer la récupération et enregistrer le profil de calibration. Arrêter la calibration Une autre séance ou calibration est déjà en cours. + Guidage cardio + Phase : %1$s + Cible : %1$d–%2$d BPM + Calibration : pic observé %1$d BPM · %2$d–%3$d %% + Dernière calibration : pic observé %1$d BPM + Cible utilisée : %1$d–%2$d BPM + ACCÉLÈRE + MAINTIENS + RALENTIS + SIGNAL CARDIO INDISPONIBLE + %1$d BPM + -- BPM + Démarrer la phase + Terminer la phase + Complète la cible et la condition de fin. + Impossible de démarrer cette phase. + Échauffement + Travail + Récupération + Retour au calme + BPM + %% calibration + BPM min + BPM max + %% min + %% max + Durée + Entrée dans la cible + Sous un seuil + Durée ou seuil + Durée max + Seuil de récupération diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt index 3e35074..965576c 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt @@ -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")) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV11ExerciseAliasMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV11ExerciseAliasMigrationTest.kt index d93914c..ada9ba6 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV11ExerciseAliasMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV11ExerciseAliasMigrationTest.kt @@ -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") } diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV16AiSessionDraftMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV16AiSessionDraftMigrationTest.kt index 1dbb9b9..18a21f7 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV16AiSessionDraftMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV16AiSessionDraftMigrationTest.kt @@ -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"), diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV25ProgramExecutionMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV25ProgramExecutionMigrationTest.kt index c75d9f4..df238a3 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV25ProgramExecutionMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV25ProgramExecutionMigrationTest.kt @@ -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 -> diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV26SleepMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV26SleepMigrationTest.kt index 94a770f..9fa22d8 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV26SleepMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV26SleepMigrationTest.kt @@ -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) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV27HeartRateMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV27HeartRateMigrationTest.kt index d93d13a..6e95c5f 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV27HeartRateMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV27HeartRateMigrationTest.kt @@ -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) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV28SessionTimelineMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV28SessionTimelineMigrationTest.kt index 4d0de17..53df764 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV28SessionTimelineMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV28SessionTimelineMigrationTest.kt @@ -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, diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV29CardioSessionMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV29CardioSessionMigrationTest.kt index 6181201..9fe855d 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV29CardioSessionMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV29CardioSessionMigrationTest.kt @@ -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, diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV30CardioCalibrationMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV30CardioCalibrationMigrationTest.kt index e16a608..f72ee21 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV30CardioCalibrationMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV30CardioCalibrationMigrationTest.kt @@ -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' " + diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV31CardioGuidanceMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV31CardioGuidanceMigrationTest.kt new file mode 100644 index 0000000..9037174 --- /dev/null +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV31CardioGuidanceMigrationTest.kt @@ -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) + } + } +} diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt index 56bf79c..85ee0a7 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt @@ -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)) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/BridgeBackupMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/BridgeBackupMigrationTest.kt index 32d6bc7..23d3750 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/BridgeBackupMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/BridgeBackupMigrationTest.kt @@ -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) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/CardioGuidanceEngineTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/CardioGuidanceEngineTest.kt new file mode 100644 index 0000000..3d68f14 --- /dev/null +++ b/android/app/src/test/java/com/labfytools/trainlog/data/CardioGuidanceEngineTest.kt @@ -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) + } +} diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/CardioGuidanceGenerationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/CardioGuidanceGenerationTest.kt new file mode 100644 index 0000000..f3f806f --- /dev/null +++ b/android/app/src/test/java/com/labfytools/trainlog/data/CardioGuidanceGenerationTest.kt @@ -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() + } + } +} diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/CardioGuidanceRepositoryTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/CardioGuidanceRepositoryTest.kt new file mode 100644 index 0000000..2e9a567 --- /dev/null +++ b/android/app/src/test/java/com/labfytools/trainlog/data/CardioGuidanceRepositoryTest.kt @@ -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) + } + } +} diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/CardioPhaseCompletionTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/CardioPhaseCompletionTest.kt new file mode 100644 index 0000000..2454e2c --- /dev/null +++ b/android/app/src/test/java/com/labfytools/trainlog/data/CardioPhaseCompletionTest.kt @@ -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)) + } +} diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/SessionPreparationExchangeTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/SessionPreparationExchangeTest.kt index 14e13ef..0b3ce7d 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/SessionPreparationExchangeTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/SessionPreparationExchangeTest.kt @@ -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"), diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/SyncGenerationServiceTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/SyncGenerationServiceTest.kt index 12a6bc2..7e3d008 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/SyncGenerationServiceTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/SyncGenerationServiceTest.kt @@ -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()) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt index c76e937..f142006 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt @@ -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()) diff --git a/docs/current_state.md b/docs/current_state.md index fe7c2c8..acb185a 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -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` | diff --git a/docs/database.md b/docs/database.md index 601f1a7..d191bca 100644 --- a/docs/database.md +++ b/docs/database.md @@ -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. diff --git a/docs/roadmap.md b/docs/roadmap.md index 2ccd5da..7088e1d 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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 diff --git a/docs/sync_exchange.md b/docs/sync_exchange.md index 854cb9b..c462eb0 100644 --- a/docs/sync_exchange.md +++ b/docs/sync_exchange.md @@ -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 diff --git a/docs/tests.md b/docs/tests.md index bf551b6..0c8cfc9 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -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. diff --git a/tests/test_cardio_guidance_exchange.py b/tests/test_cardio_guidance_exchange.py new file mode 100644 index 0000000..1c3cfd6 --- /dev/null +++ b/tests/test_cardio_guidance_exchange.py @@ -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() diff --git a/tests/test_sync_deployment_tools.py b/tests/test_sync_deployment_tools.py index 9a2b9be..5b8d60f 100644 --- a/tests/test_sync_deployment_tools.py +++ b/tests/test_sync_deployment_tools.py @@ -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"]) diff --git a/tools/cardio_calibration_exchange.py b/tools/cardio_calibration_exchange.py index d888d7c..92099ce 100644 --- a/tools/cardio_calibration_exchange.py +++ b/tools/cardio_calibration_exchange.py @@ -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() diff --git a/tools/cardio_guidance_exchange.py b/tools/cardio_guidance_exchange.py new file mode 100644 index 0000000..37c259a --- /dev/null +++ b/tools/cardio_guidance_exchange.py @@ -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() diff --git a/tools/causal_delete_exchange.py b/tools/causal_delete_exchange.py index 9a7c08f..3d2a76f 100644 --- a/tools/causal_delete_exchange.py +++ b/tools/causal_delete_exchange.py @@ -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): diff --git a/tools/execution_draft_exchange.py b/tools/execution_draft_exchange.py index a2374c6..3e86d7e 100644 --- a/tools/execution_draft_exchange.py +++ b/tools/execution_draft_exchange.py @@ -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): diff --git a/tools/export_ai_history.py b/tools/export_ai_history.py index 88264b4..9961af6 100644 --- a/tools/export_ai_history.py +++ b/tools/export_ai_history.py @@ -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", diff --git a/tools/export_ai_session_drafts.py b/tools/export_ai_session_drafts.py index 5d626c1..82e1002 100644 --- a/tools/export_ai_session_drafts.py +++ b/tools/export_ai_session_drafts.py @@ -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: diff --git a/tools/export_equipment_associations.py b/tools/export_equipment_associations.py index 896f757..caccb1a 100644 --- a/tools/export_equipment_associations.py +++ b/tools/export_equipment_associations.py @@ -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")) diff --git a/tools/export_equipment_definitions.py b/tools/export_equipment_definitions.py index f80764d..803e33e 100644 --- a/tools/export_equipment_definitions.py +++ b/tools/export_equipment_definitions.py @@ -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( diff --git a/tools/export_exercise_aliases.py b/tools/export_exercise_aliases.py index 3ca21b0..cfbbbef 100644 --- a/tools/export_exercise_aliases.py +++ b/tools/export_exercise_aliases.py @@ -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" diff --git a/tools/export_exercise_body_zones.py b/tools/export_exercise_body_zones.py index 15a580c..891789d 100644 --- a/tools/export_exercise_body_zones.py +++ b/tools/export_exercise_body_zones.py @@ -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 = [] diff --git a/tools/export_exercise_profile_state.py b/tools/export_exercise_profile_state.py index b61b64c..334fcdb 100644 --- a/tools/export_exercise_profile_state.py +++ b/tools/export_exercise_profile_state.py @@ -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, diff --git a/tools/export_pc_catalog.py b/tools/export_pc_catalog.py index 4fa0999..4f43db5 100755 --- a/tools/export_pc_catalog.py +++ b/tools/export_pc_catalog.py @@ -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}" diff --git a/tools/export_pc_mobile.py b/tools/export_pc_mobile.py index 812c34c..3fec025 100644 --- a/tools/export_pc_mobile.py +++ b/tools/export_pc_mobile.py @@ -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") diff --git a/tools/export_programs.py b/tools/export_programs.py index 182a83b..6ce12ad 100644 --- a/tools/export_programs.py +++ b/tools/export_programs.py @@ -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( diff --git a/tools/export_session_preparations.py b/tools/export_session_preparations.py index 16465cb..8ab04b8 100644 --- a/tools/export_session_preparations.py +++ b/tools/export_session_preparations.py @@ -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 = [] diff --git a/tools/export_training_feedback.py b/tools/export_training_feedback.py index 0e55b60..5030170 100644 --- a/tools/export_training_feedback.py +++ b/tools/export_training_feedback.py @@ -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() diff --git a/tools/heart_rate_exchange.py b/tools/heart_rate_exchange.py index 06a9c54..cc069a3 100644 --- a/tools/heart_rate_exchange.py +++ b/tools/heart_rate_exchange.py @@ -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() diff --git a/tools/import_ai_session_draft.py b/tools/import_ai_session_draft.py index e8d75dc..774db49 100644 --- a/tools/import_ai_session_draft.py +++ b/tools/import_ai_session_draft.py @@ -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") diff --git a/tools/import_equipment_associations.py b/tools/import_equipment_associations.py index c7a993a..d90bac9 100644 --- a/tools/import_equipment_associations.py +++ b/tools/import_equipment_associations.py @@ -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") diff --git a/tools/import_equipment_definitions.py b/tools/import_equipment_definitions.py index ba40569..80481c9 100644 --- a/tools/import_equipment_definitions.py +++ b/tools/import_equipment_definitions.py @@ -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 diff --git a/tools/import_exercise_aliases.py b/tools/import_exercise_aliases.py index 4d82976..0c5c441 100644 --- a/tools/import_exercise_aliases.py +++ b/tools/import_exercise_aliases.py @@ -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}") diff --git a/tools/import_exercise_body_zones.py b/tools/import_exercise_body_zones.py index a0f2687..b0b2cf5 100644 --- a/tools/import_exercise_body_zones.py +++ b/tools/import_exercise_body_zones.py @@ -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 diff --git a/tools/import_exercise_profile_state.py b/tools/import_exercise_profile_state.py index 2c8f09f..6e29ab2 100644 --- a/tools/import_exercise_profile_state.py +++ b/tools/import_exercise_profile_state.py @@ -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"] diff --git a/tools/import_mobile_export.py b/tools/import_mobile_export.py index f5d1e7f..ecc747d 100755 --- a/tools/import_mobile_export.py +++ b/tools/import_mobile_export.py @@ -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")} diff --git a/tools/import_training_feedback.py b/tools/import_training_feedback.py index 6dc7415..4f751c8 100644 --- a/tools/import_training_feedback.py +++ b/tools/import_training_feedback.py @@ -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"]): diff --git a/tools/package_sync_candidate.py b/tools/package_sync_candidate.py index bd1430c..d3840de 100644 --- a/tools/package_sync_candidate.py +++ b/tools/package_sync_candidate.py @@ -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", ], diff --git a/tools/program_execution_exchange.py b/tools/program_execution_exchange.py index 161a06c..9dafc5c 100644 --- a/tools/program_execution_exchange.py +++ b/tools/program_execution_exchange.py @@ -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 diff --git a/tools/session_timeline_exchange.py b/tools/session_timeline_exchange.py index db54faf..16a4f88 100644 --- a/tools/session_timeline_exchange.py +++ b/tools/session_timeline_exchange.py @@ -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() diff --git a/tools/sleep_diary_exchange.py b/tools/sleep_diary_exchange.py index b0ae44f..a5911a4 100644 --- a/tools/sleep_diary_exchange.py +++ b/tools/sleep_diary_exchange.py @@ -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") diff --git a/tools/sync_generation_exchange.py b/tools/sync_generation_exchange.py index 4c6fa25..74f2995 100644 --- a/tools/sync_generation_exchange.py +++ b/tools/sync_generation_exchange.py @@ -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) diff --git a/tui/include/trainlog/database.h b/tui/include/trainlog/database.h index 90b273e..8c59a5f 100644 --- a/tui/include/trainlog/database.h +++ b/tui/include/trainlog/database.h @@ -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; diff --git a/tui/meson.build b/tui/meson.build index 393d38e..e1a0493 100644 --- a/tui/meson.build +++ b/tui/meson.build @@ -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'), diff --git a/tui/src/database.c b/tui/src/database.c index e1bbe52..77d53bb 100644 --- a/tui/src/database.c +++ b/tui/src/database.c @@ -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); diff --git a/tui/tests/test_schema_v29_migration.c b/tui/tests/test_schema_v29_migration.c index 0dddd74..40d4b73 100644 --- a/tui/tests/test_schema_v29_migration.c +++ b/tui/tests/test_schema_v29_migration.c @@ -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); diff --git a/tui/tests/test_schema_v30_migration.c b/tui/tests/test_schema_v30_migration.c index a4525bf..b1f6f8e 100644 --- a/tui/tests/test_schema_v30_migration.c +++ b/tui/tests/test_schema_v30_migration.c @@ -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; diff --git a/tui/tests/test_schema_v31_migration.c b/tui/tests/test_schema_v31_migration.c index 5781a5c..ea92aa2 100644 --- a/tui/tests/test_schema_v31_migration.c +++ b/tui/tests/test_schema_v31_migration.c @@ -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; diff --git a/tui/tests/test_schema_v32_migration.c b/tui/tests/test_schema_v32_migration.c index 3fe5d72..d9fe2eb 100644 --- a/tui/tests/test_schema_v32_migration.c +++ b/tui/tests/test_schema_v32_migration.c @@ -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; diff --git a/tui/tests/test_schema_v33_migration.c b/tui/tests/test_schema_v33_migration.c index da7e0ba..5f20354 100644 --- a/tui/tests/test_schema_v33_migration.c +++ b/tui/tests/test_schema_v33_migration.c @@ -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; diff --git a/tui/tests/test_schema_v34_migration.c b/tui/tests/test_schema_v34_migration.c new file mode 100644 index 0000000..ca1fba5 --- /dev/null +++ b/tui/tests/test_schema_v34_migration.c @@ -0,0 +1,69 @@ +#define _POSIX_C_SOURCE 200809L + +#include +#include +#include +#include + +#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; +} diff --git a/tui/tests/test_schema_v7_migration.c b/tui/tests/test_schema_v7_migration.c index 8637aa4..16d9fda 100644 --- a/tui/tests/test_schema_v7_migration.c +++ b/tui/tests/test_schema_v7_migration.c @@ -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; diff --git a/tui/tests/test_schema_v9_migration.c b/tui/tests/test_schema_v9_migration.c index 48ace19..e427a53 100644 --- a/tui/tests/test_schema_v9_migration.c +++ b/tui/tests/test_schema_v9_migration.c @@ -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);