From 8371d5e16c97aceddb6a6c7e603a301e3c2e27cd Mon Sep 17 00:00:00 2001 From: fy59 Date: Wed, 23 Sep 2026 13:20:00 +0200 Subject: [PATCH] feat(cardio): add measured calibration profile --- CHANGELOG.md | 2 + README.md | 2 +- .../trainlog/data/AndroidBackupService.kt | 4 +- .../trainlog/data/SyncGenerationService.kt | 21 + .../trainlog/data/TrainlogRepository.kt | 613 +++++++++++++++++- .../trainlog/model/CardioCalibrationModels.kt | 37 ++ .../labfytools/trainlog/ui/AndroidAppShell.kt | 1 + .../labfytools/trainlog/ui/AppNavigation.kt | 1 + .../trainlog/ui/CardioCalibrationScreen.kt | 172 +++++ .../labfytools/trainlog/ui/SectionScreens.kt | 8 + .../com/labfytools/trainlog/ui/TrainlogApp.kt | 16 +- .../app/src/main/res/values-en/strings.xml | 25 + android/app/src/main/res/values/strings.xml | 25 + .../data/AndroidV10PlanningMigrationTest.kt | 2 +- .../AndroidV11ExerciseAliasMigrationTest.kt | 2 +- .../AndroidV16AiSessionDraftMigrationTest.kt | 2 +- ...AndroidV25ProgramExecutionMigrationTest.kt | 2 +- .../data/AndroidV26SleepMigrationTest.kt | 2 +- .../data/AndroidV27HeartRateMigrationTest.kt | 2 +- .../AndroidV28SessionTimelineMigrationTest.kt | 2 +- .../AndroidV29CardioSessionMigrationTest.kt | 2 +- ...ndroidV30CardioCalibrationMigrationTest.kt | 69 ++ .../labfytools/trainlog/data/BodyZonesTest.kt | 2 +- .../data/BridgeBackupMigrationTest.kt | 2 +- .../data/CardioCalibrationRepositoryTest.kt | 187 ++++++ .../data/SessionPreparationExchangeTest.kt | 2 +- .../data/SyncGenerationServiceTest.kt | 2 +- .../data/TrainlogRepositoryDraftTest.kt | 4 +- docs/current_state.md | 7 +- docs/database.md | 8 + docs/roadmap.md | 2 +- docs/sync_exchange.md | 10 +- docs/tests.md | 10 + tests/test_cardio_calibration_exchange.py | 175 +++++ tests/test_sync_deployment_tools.py | 5 +- tools/cardio_calibration_exchange.py | 282 ++++++++ tools/causal_delete_exchange.py | 4 +- tools/execution_draft_exchange.py | 4 +- tools/export_ai_history.py | 4 +- tools/export_ai_session_drafts.py | 4 +- tools/export_equipment_associations.py | 4 +- tools/export_equipment_definitions.py | 4 +- tools/export_exercise_aliases.py | 4 +- tools/export_exercise_body_zones.py | 4 +- tools/export_exercise_profile_state.py | 2 +- tools/export_pc_catalog.py | 2 +- tools/export_pc_mobile.py | 6 +- tools/export_programs.py | 2 +- tools/export_session_preparations.py | 2 +- tools/export_training_feedback.py | 4 +- tools/heart_rate_exchange.py | 4 +- tools/import_ai_session_draft.py | 4 +- tools/import_equipment_associations.py | 4 +- tools/import_equipment_definitions.py | 4 +- tools/import_exercise_aliases.py | 4 +- tools/import_exercise_body_zones.py | 4 +- tools/import_exercise_profile_state.py | 4 +- tools/import_mobile_export.py | 8 +- tools/import_training_feedback.py | 4 +- tools/package_sync_candidate.py | 6 +- tools/program_execution_exchange.py | 2 +- tools/session_timeline_exchange.py | 4 +- tools/sleep_diary_exchange.py | 4 +- tools/sync_generation_exchange.py | 10 +- tui/include/trainlog/database.h | 2 +- tui/meson.build | 13 + tui/src/database.c | 32 +- tui/tests/test_schema_v29_migration.c | 4 +- tui/tests/test_schema_v30_migration.c | 2 +- tui/tests/test_schema_v31_migration.c | 2 +- tui/tests/test_schema_v32_migration.c | 2 +- tui/tests/test_schema_v33_migration.c | 67 ++ tui/tests/test_schema_v7_migration.c | 8 +- tui/tests/test_schema_v9_migration.c | 2 +- 74 files changed, 1854 insertions(+), 106 deletions(-) create mode 100644 android/app/src/main/java/com/labfytools/trainlog/model/CardioCalibrationModels.kt create mode 100644 android/app/src/main/java/com/labfytools/trainlog/ui/CardioCalibrationScreen.kt create mode 100644 android/app/src/test/java/com/labfytools/trainlog/data/AndroidV30CardioCalibrationMigrationTest.kt create mode 100644 android/app/src/test/java/com/labfytools/trainlog/data/CardioCalibrationRepositoryTest.kt create mode 100644 tests/test_cardio_calibration_exchange.py create mode 100644 tools/cardio_calibration_exchange.py create mode 100644 tui/tests/test_schema_v33_migration.c diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d49883..2d321fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,8 @@ exact replay is idempotent and identity collision with non-cardio history is rejected. +- Added the first versioned Cardio calibration workflow. Android schema v30 creates one reserved system exercise outside the normal exercise catalogue, starts a real cardio session and HR capture, records only measured observed peak BPM, and requires at least three minutes of recovery. +1/+2/+3 minute recovery facts accept only real samples within a bounded ten-second window; missing signal therefore remains missing instead of being backfilled from a later reconnect. Completed immutable profiles synchronize Android→desktop through `trainlog-cardio-calibrations` V1 into desktop schema v33, where the importer proves peak and recovery points against the synchronized raw HR capture before accepting them. + Development opened after stable v0.1.4 release. ## 0.1.4 — 2026-09-22 diff --git a/README.md b/README.md index 3fcb649..4812e0f 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, and the dedicated synchronized `cardio` session type; Calibration cardio is the current implementation cursor. +0.1.5 development now has validated persistent CYCPLUS acquisition, the global `♥ BPM` indicator, training exercise timestamps, one-tap Android Sleep capture, dedicated synchronized `cardio` sessions, and a versioned measured Cardio calibration; BPM-guided cardio phases are the current implementation cursor. ## Releases 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 75d915f..db5db06 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..29 + manifest.getInt("schema") in 17..30 ) { "Unsupported backup." } @@ -319,7 +319,7 @@ internal class AndroidBackupService( } ) require(db.rawQuery("PRAGMA foreign_key_check", null).use { !it.moveToFirst() }) - require(db.version in 17..29) { "Unsupported Android schema ${db.version}." } + require(db.version in 17..30) { "Unsupported Android schema ${db.version}." } return db.version } finally { db.close() 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 c3e293f..ac71c8f 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 @@ -72,6 +72,8 @@ internal class SyncGenerationService(private val repository: TrainlogRepository) Kind("trainlog-program-executions", 1, "program-executions-v1.json", false), "sleep-diary" to Kind("trainlog-sleep-diary", 1, "sleep-diary-v1.json", false), "heart-rate" to Kind("trainlog-heart-rate", 1, "heart-rate-v1.json", false), + "cardio-calibrations" to + Kind("trainlog-cardio-calibrations", 1, "cardio-calibrations-v1.json", false), "session-timeline" to Kind("trainlog-session-timeline", 1, "session-timeline-v1.json", false), ) @@ -435,6 +437,17 @@ internal class SyncGenerationService(private val repository: TrainlogRepository) arrayOf(generationId, captures.getJSONObject(index).getString("capture_id")), ) } + val calibrations = JSONObject(checkNotNull(artifacts["cardio-calibrations"])) + val calibrationItems = calibrations.getJSONArray("calibrations") + for (index in 0 until calibrationItems.length()) { + db.execSQL( + "INSERT INTO cardio_calibration_generation(generation_id,calibration_id) VALUES(?,?)", + arrayOf( + generationId, + calibrationItems.getJSONObject(index).getString("calibration_id"), + ), + ) + } val timeline = JSONObject(checkNotNull(artifacts["session-timeline"])) val timelineSessions = timeline.getJSONArray("sessions") for (index in 0 until timelineSessions.length()) { @@ -1092,6 +1105,14 @@ internal class SyncGenerationService(private val repository: TrainlogRepository) "m.capture_id=heart_rate_captures.capture_id)", arrayOf(ack.getString("consumed_at"), ack.getString("generation_id")), ) + db.execSQL( + "UPDATE cardio_calibrations SET acknowledged_at=? WHERE " + + "acknowledged_at IS NULL AND EXISTS(" + + "SELECT 1 FROM cardio_calibration_generation m " + + "WHERE m.generation_id=? AND " + + "m.calibration_id=cardio_calibrations.calibration_id)", + arrayOf(ack.getString("consumed_at"), ack.getString("generation_id")), + ) db.execSQL( "UPDATE session_timeline_sessions SET acknowledged_at=? WHERE " + "acknowledged_at IS NULL AND EXISTS(" + 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 15b1dac..d205b49 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 @@ -17,6 +17,9 @@ import com.labfytools.trainlog.model.ActiveSessionDraft import com.labfytools.trainlog.model.ActiveSessionTimelineContext import com.labfytools.trainlog.model.AiSessionDraft import com.labfytools.trainlog.model.BodyObservationDraft +import com.labfytools.trainlog.model.CardioCalibrationPhase +import com.labfytools.trainlog.model.CardioCalibrationProfile +import com.labfytools.trainlog.model.CardioCalibrationRecoveryPoint import com.labfytools.trainlog.model.BodyObservationSummary import com.labfytools.trainlog.model.ExerciseDataFields import com.labfytools.trainlog.model.ExerciseProfile @@ -65,6 +68,7 @@ import java.io.StringReader import java.nio.ByteBuffer import java.nio.charset.StandardCharsets import java.security.MessageDigest +import java.time.Duration import java.time.LocalDate import java.time.OffsetDateTime import java.time.temporal.WeekFields @@ -530,6 +534,13 @@ class TrainlogRepository( data class DatabaseError(val message: String) : SessionExerciseTimingResult } + sealed interface CardioCalibrationResult { + data class Applied(val profile: CardioCalibrationProfile) : CardioCalibrationResult + data class Invalid(val message: String) : CardioCalibrationResult + data object Conflict : CardioCalibrationResult + data class DatabaseError(val message: String) : CardioCalibrationResult + } + private fun legacySessionTypeWire(type: SessionType): String = if (type == SessionType.MAX_TEST) SessionType.MAX_TEST.wireValue else SessionType.TRAINING.wireValue @@ -1675,6 +1686,445 @@ class TrainlogRepository( } } + private fun readCardioCalibrationProfile( + db: SQLiteDatabase, + calibrationId: String, + ): CardioCalibrationProfile? { + val row = + db.rawQuery( + "SELECT calibration_id,protocol_version,session_id,entry_id,phase,started_at," + + "phase_started_at,effort_end_at,ended_at,heart_rate_capture_id,observed_peak_bpm " + + "FROM cardio_calibrations WHERE calibration_id=?", + arrayOf(calibrationId), + ).use { cursor -> + if (!cursor.moveToFirst()) null + else arrayOf( + cursor.getString(0), + cursor.getInt(1), + cursor.getString(2), + cursor.getString(3), + cursor.getString(4), + cursor.getString(5), + cursor.getString(6), + if (cursor.isNull(7)) null else cursor.getString(7), + if (cursor.isNull(8)) null else cursor.getString(8), + if (cursor.isNull(9)) null else cursor.getString(9), + if (cursor.isNull(10)) null else cursor.getInt(10), + ) + } ?: return null + val recovery = + db.rawQuery( + "SELECT target_offset_seconds,observed_at,bpm FROM cardio_calibration_recovery " + + "WHERE calibration_id=? ORDER BY target_offset_seconds", + arrayOf(calibrationId), + ).use { cursor -> + buildList { + while (cursor.moveToNext()) { + add( + CardioCalibrationRecoveryPoint( + targetOffsetSeconds = cursor.getInt(0), + observedAt = cursor.getString(1), + bpm = cursor.getInt(2), + ), + ) + } + } + } + return CardioCalibrationProfile( + calibrationId = row[0] as String, + protocolVersion = row[1] as Int, + sessionId = row[2] as String, + entryId = row[3] as String, + phase = CardioCalibrationPhase.fromWire(row[4] as String), + startedAt = row[5] as String, + phaseStartedAt = row[6] as String, + effortEndAt = row[7] as String?, + endedAt = row[8] as String?, + heartRateCaptureId = row[9] as String?, + observedPeakBpm = row[10] as Int?, + recovery = recovery, + ) + } + + fun activeCardioCalibration(): CardioCalibrationProfile? { + val db = database.readableDatabase + val id = + db.rawQuery( + "SELECT calibration_id FROM cardio_calibrations WHERE active_slot=1 LIMIT 1", + null, + ).use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null } + ?: return null + return readCardioCalibrationProfile(db, id) + } + + fun listCompletedCardioCalibrations(limit: Int = 32): List { + require(limit in 1..128) + val db = database.readableDatabase + val ids = + db.rawQuery( + "SELECT calibration_id FROM cardio_calibrations WHERE phase='completed' " + + "ORDER BY ended_at DESC,calibration_id DESC LIMIT ?", + arrayOf(limit.toString()), + ).use { cursor -> + buildList { + while (cursor.moveToNext()) add(cursor.getString(0)) + } + } + return ids.mapNotNull { readCardioCalibrationProfile(db, it) } + } + + internal fun buildCardioCalibrationsV1Json(): String { + val db = database.readableDatabase + val calibrations = JSONArray() + db.rawQuery( + "SELECT c.calibration_id FROM cardio_calibrations c " + + "WHERE c.phase='completed' AND c.acknowledged_at IS NULL AND NOT EXISTS(" + + "SELECT 1 FROM cardio_calibration_generation m " + + "JOIN sync_generations g ON g.generation_id=m.generation_id " + + "WHERE m.calibration_id=c.calibration_id AND " + + "g.status IN('captured','published','waiting_acknowledgement')) " + + "ORDER BY c.ended_at,c.calibration_id LIMIT ?", + arrayOf(MAX_CARDIO_CALIBRATIONS_PER_GENERATION.toString()), + ).use { cursor -> + while (cursor.moveToNext()) { + val profile = checkNotNull(readCardioCalibrationProfile(db, cursor.getString(0))) + val recovery = JSONArray() + profile.recovery.forEach { point -> + recovery.put( + JSONObject() + .put("target_offset_seconds", point.targetOffsetSeconds) + .put("observed_at", point.observedAt) + .put("bpm", point.bpm), + ) + } + calibrations.put( + JSONObject() + .put("calibration_id", profile.calibrationId) + .put("protocol_version", profile.protocolVersion) + .put("session_id", profile.sessionId) + .put("entry_id", profile.entryId) + .put("started_at", profile.startedAt) + .put("effort_end_at", checkNotNull(profile.effortEndAt)) + .put("ended_at", checkNotNull(profile.endedAt)) + .put("heart_rate_capture_id", checkNotNull(profile.heartRateCaptureId)) + .put("observed_peak_bpm", checkNotNull(profile.observedPeakBpm)) + .put("recovery", recovery), + ) + } + } + return JSONObject() + .put("format", "trainlog-cardio-calibrations") + .put("version", 1) + .put("generated_at", OffsetDateTime.now().toString()) + .put("calibrations", calibrations) + .toString() + } + + fun startCardioCalibration( + startedAt: String = OffsetDateTime.now().toString(), + ): CardioCalibrationResult { + if (TrainlogTimestamp.parse(startedAt) == null) { + return CardioCalibrationResult.Invalid("Invalid calibration start timestamp.") + } + if (activeCardioCalibration() != null || loadActiveSessionDraft() !is ActiveDraftLoadResult.None) { + return CardioCalibrationResult.Conflict + } + val exerciseRow = + findExerciseRow( + database.readableDatabase, + "exercise_id=?", + arrayOf(CARDIO_CALIBRATION_EXERCISE_ID), + ) ?: return CardioCalibrationResult.DatabaseError("Calibration exercise is unavailable.") + val exercise = + ExerciseProfile( + exerciseId = exerciseRow.exerciseId, + name = exerciseRow.name, + normalizedName = exerciseRow.normalizedName, + recordingMode = exerciseRow.recordingMode, + trackingMode = exerciseRow.trackingMode, + dataFields = exerciseRow.dataFields, + ) + val entry = SessionExerciseDraft(exercise = exercise) + when ( + saveActiveSessionDraft( + ActiveSessionDraft( + startedAt = startedAt, + exercises = listOf(entry), + sessionType = SessionType.CARDIO, + ), + ) + ) { + ActiveDraftMutationResult.Saved -> Unit + is ActiveDraftMutationResult.Error -> + return CardioCalibrationResult.DatabaseError("Calibration draft creation failed.") + } + val loaded = loadActiveSessionDraft() as? ActiveDraftLoadResult.Loaded + ?: return CardioCalibrationResult.DatabaseError("Calibration draft is unavailable.") + val sessionId = loaded.draft.sessionId + ?: return CardioCalibrationResult.DatabaseError("Calibration session identity is missing.") + val actualStartedAt = loaded.draft.startedAt + ?: return CardioCalibrationResult.DatabaseError("Calibration start timestamp is missing.") + val actualEntry = loaded.draft.exercises.singleOrNull() + ?: return CardioCalibrationResult.DatabaseError("Calibration occurrence is missing.") + when (startActiveSessionExercise(actualEntry.entryId, actualStartedAt)) { + is SessionExerciseTimingResult.Started -> Unit + else -> { + discardActiveSessionDraft() + return CardioCalibrationResult.DatabaseError("Calibration timing could not start.") + } + } + + val calibrationId = "cal_" + UUID.randomUUID() + val db = database.writableDatabase + return try { + db.execSQL( + "INSERT INTO cardio_calibrations(" + + "calibration_id,protocol_version,session_id,entry_id,phase,started_at," + + "phase_started_at,effort_end_at,ended_at,heart_rate_capture_id," + + "observed_peak_bpm,active_slot,acknowledged_at) " + + "VALUES(?,?,?,?,?,?,?,NULL,NULL,NULL,NULL,1,NULL)", + arrayOf( + calibrationId, + CARDIO_CALIBRATION_PROTOCOL_VERSION, + sessionId, + actualEntry.entryId, + CardioCalibrationPhase.WARMUP.wireValue, + actualStartedAt, + actualStartedAt, + ), + ) + CardioCalibrationResult.Applied( + checkNotNull(readCardioCalibrationProfile(db, calibrationId)), + ) + } catch (error: Exception) { + discardActiveSessionDraft() + CardioCalibrationResult.DatabaseError( + error.message ?: "Calibration creation failed.", + ) + } + } + + fun advanceCardioCalibrationPhase( + at: String = OffsetDateTime.now().toString(), + ): CardioCalibrationResult { + if (TrainlogTimestamp.parse(at) == null) { + return CardioCalibrationResult.Invalid("Invalid calibration phase timestamp.") + } + val current = activeCardioCalibration() ?: return CardioCalibrationResult.Invalid("No calibration is active.") + val next = + when (current.phase) { + CardioCalibrationPhase.WARMUP -> CardioCalibrationPhase.PROGRESSIVE + CardioCalibrationPhase.PROGRESSIVE -> CardioCalibrationPhase.HIGH_EFFORT + else -> return CardioCalibrationResult.Invalid("Calibration phase cannot advance.") + } + if (TrainlogTimestamp.parse(at)!! < TrainlogTimestamp.parse(current.phaseStartedAt)!!) { + return CardioCalibrationResult.Invalid("Calibration phase cannot move backwards in time.") + } + val db = database.writableDatabase + return try { + db.execSQL( + "UPDATE cardio_calibrations SET phase=?,phase_started_at=? " + + "WHERE calibration_id=? AND active_slot=1", + arrayOf(next.wireValue, at, current.calibrationId), + ) + CardioCalibrationResult.Applied( + checkNotNull(readCardioCalibrationProfile(db, current.calibrationId)), + ) + } catch (error: Exception) { + CardioCalibrationResult.DatabaseError(error.message ?: "Calibration phase update failed.") + } + } + + fun markCardioCalibrationEffortEnd( + at: String = OffsetDateTime.now().toString(), + ): CardioCalibrationResult { + if (TrainlogTimestamp.parse(at) == null) { + return CardioCalibrationResult.Invalid("Invalid effort-end timestamp.") + } + val current = activeCardioCalibration() ?: return CardioCalibrationResult.Invalid("No calibration is active.") + if (current.phase != CardioCalibrationPhase.HIGH_EFFORT) { + return CardioCalibrationResult.Invalid("High-effort phase is not active.") + } + if (current.observedPeakBpm == null || current.heartRateCaptureId == null) { + return CardioCalibrationResult.Invalid("No measured heart-rate sample is available.") + } + if (TrainlogTimestamp.parse(at)!! < TrainlogTimestamp.parse(current.phaseStartedAt)!!) { + return CardioCalibrationResult.Invalid("Effort cannot end before its phase starts.") + } + val db = database.writableDatabase + return try { + db.execSQL( + "UPDATE cardio_calibrations SET phase='recovery',phase_started_at=?,effort_end_at=? " + + "WHERE calibration_id=? AND active_slot=1", + arrayOf(at, at, current.calibrationId), + ) + CardioCalibrationResult.Applied( + checkNotNull(readCardioCalibrationProfile(db, current.calibrationId)), + ) + } catch (error: Exception) { + CardioCalibrationResult.DatabaseError(error.message ?: "Effort-end update failed.") + } + } + + fun finishCardioCalibration( + endedAt: String = OffsetDateTime.now().toString(), + ): CardioCalibrationResult { + if (TrainlogTimestamp.parse(endedAt) == null) { + return CardioCalibrationResult.Invalid("Invalid calibration end timestamp.") + } + val current = activeCardioCalibration() ?: return CardioCalibrationResult.Invalid("No calibration is active.") + if ( + current.phase != CardioCalibrationPhase.RECOVERY || + current.observedPeakBpm == null || + current.heartRateCaptureId == null + ) { + return CardioCalibrationResult.Invalid("Calibration is not ready to finish.") + } + val effortEnd = + current.effortEndAt?.let { runCatching { OffsetDateTime.parse(it) }.getOrNull() } + ?: return CardioCalibrationResult.Invalid("Calibration effort end is missing.") + val parsedEnd = OffsetDateTime.parse(endedAt) + if (parsedEnd.isBefore(effortEnd.plusSeconds(CARDIO_CALIBRATION_MIN_RECOVERY_SECONDS))) { + return CardioCalibrationResult.Invalid("Calibration recovery must last at least three minutes.") + } + val timing = + listActiveSessionExerciseTimings().singleOrNull { + it.entryId == current.entryId && it.active + } ?: return CardioCalibrationResult.Invalid("Calibration exercise is not active.") + val durationSeconds = + Duration.between(OffsetDateTime.parse(timing.startedAt), OffsetDateTime.parse(endedAt)).seconds + if (durationSeconds !in 1..MAX_PLAN_DURATION_SECONDS.toLong()) { + return CardioCalibrationResult.Invalid("Calibration duration is invalid.") + } + val loaded = loadActiveSessionDraft() as? ActiveDraftLoadResult.Loaded + ?: return CardioCalibrationResult.Invalid("Calibration draft is unavailable.") + if (loaded.draft.sessionId != current.sessionId || loaded.draft.sessionType != SessionType.CARDIO) { + return CardioCalibrationResult.Conflict + } + val updatedEntries = loaded.draft.exercises.map { occurrence -> + if (occurrence.entryId == current.entryId) { + occurrence.copy(continuousDurationSeconds = durationSeconds.toInt()) + } else occurrence + } + when (saveActiveSessionDraft(loaded.draft.copy(exercises = updatedEntries))) { + ActiveDraftMutationResult.Saved -> Unit + is ActiveDraftMutationResult.Error -> + return CardioCalibrationResult.DatabaseError("Calibration duration could not be saved.") + } + when (finishActiveSessionExercise(current.entryId, endedAt)) { + is SessionExerciseTimingResult.Finished -> Unit + else -> return CardioCalibrationResult.DatabaseError("Calibration timing could not finish.") + } + when (finalizeActiveSessionDraft()) { + is FinalizeActiveDraftResult.Saved -> Unit + else -> return CardioCalibrationResult.DatabaseError("Calibration session could not finalize.") + } + val db = database.writableDatabase + return try { + db.execSQL( + "UPDATE cardio_calibrations SET phase='completed',phase_started_at=?,ended_at=?," + + "active_slot=NULL WHERE calibration_id=? AND active_slot=1", + arrayOf(endedAt, endedAt, current.calibrationId), + ) + CardioCalibrationResult.Applied( + checkNotNull(readCardioCalibrationProfile(db, current.calibrationId)), + ) + } catch (error: Exception) { + CardioCalibrationResult.DatabaseError(error.message ?: "Calibration completion failed.") + } + } + + fun abortCardioCalibration( + endedAt: String = OffsetDateTime.now().toString(), + ): CardioCalibrationResult { + if (TrainlogTimestamp.parse(endedAt) == null) { + return CardioCalibrationResult.Invalid("Invalid calibration abort timestamp.") + } + val current = activeCardioCalibration() ?: return CardioCalibrationResult.Invalid("No calibration is active.") + val db = database.writableDatabase + return try { + db.execSQL( + "UPDATE cardio_calibrations SET phase='aborted',phase_started_at=?,ended_at=?," + + "heart_rate_capture_id=NULL,active_slot=NULL WHERE calibration_id=? AND active_slot=1", + arrayOf(endedAt, endedAt, current.calibrationId), + ) + discardActiveSessionDraft() + CardioCalibrationResult.Applied( + checkNotNull(readCardioCalibrationProfile(db, current.calibrationId)), + ) + } catch (error: Exception) { + CardioCalibrationResult.DatabaseError(error.message ?: "Calibration abort failed.") + } + } + + private fun updateActiveCardioCalibrationFromMeasurement( + captureId: String, + observedAt: String, + measurement: ParsedHeartRateMeasurement, + ) { + val observed = runCatching { OffsetDateTime.parse(observedAt) }.getOrNull() ?: return + val db = database.writableDatabase + db.beginTransaction() + try { + val row = + db.rawQuery( + "SELECT calibration_id,phase,effort_end_at,heart_rate_capture_id,observed_peak_bpm " + + "FROM cardio_calibrations WHERE active_slot=1 LIMIT 1", + null, + ).use { cursor -> + if (!cursor.moveToFirst()) null + else arrayOf( + cursor.getString(0), + cursor.getString(1), + if (cursor.isNull(2)) null else cursor.getString(2), + if (cursor.isNull(3)) null else cursor.getString(3), + if (cursor.isNull(4)) null else cursor.getInt(4), + ) + } ?: return + val calibrationId = row[0] as String + val phase = CardioCalibrationPhase.fromWire(row[1] as String) + val existingCapture = row[3] as String? + if (existingCapture != null && existingCapture != captureId) return + if (existingCapture == null) { + db.execSQL( + "UPDATE cardio_calibrations SET heart_rate_capture_id=? WHERE calibration_id=?", + arrayOf(captureId, calibrationId), + ) + } + if (phase in setOf( + CardioCalibrationPhase.WARMUP, + CardioCalibrationPhase.PROGRESSIVE, + CardioCalibrationPhase.HIGH_EFFORT, + ) + ) { + val peak = row[4] as Int? + if (peak == null || measurement.bpm > peak) { + db.execSQL( + "UPDATE cardio_calibrations SET observed_peak_bpm=? WHERE calibration_id=?", + arrayOf(measurement.bpm, calibrationId), + ) + } + } else if (phase == CardioCalibrationPhase.RECOVERY) { + val effortEnd = OffsetDateTime.parse(row[2] as String? ?: return) + for (offset in CARDIO_CALIBRATION_RECOVERY_OFFSETS) { + val target = effortEnd.plusSeconds(offset.toLong()) + val latestAccepted = + target.plusSeconds(CARDIO_CALIBRATION_RECOVERY_SAMPLE_MAX_LAG_SECONDS) + if (!observed.isBefore(target) && !observed.isAfter(latestAccepted)) { + db.execSQL( + "INSERT OR IGNORE INTO cardio_calibration_recovery(" + + "calibration_id,target_offset_seconds,observed_at,bpm) VALUES(?,?,?,?)", + arrayOf(calibrationId, offset, observedAt, measurement.bpm), + ) + } + } + } + db.setTransactionSuccessful() + } finally { + db.endTransaction() + } + } + internal fun recordLiveHeartRateForActiveSession( sensorName: String?, observedAt: String, @@ -1714,17 +2164,28 @@ class TrainlogRepository( } } - return appendHeartRateSample( - captureId = current.captureId, - observedAt = observedAt, - bpm = measurement.bpm, - sensorContactDetected = measurement.sensorContactDetected, - energyExpended = measurement.energyExpended, - rrIntervals = - measurement.rrIntervals1024.mapIndexed { index, value -> - HeartRateRrInterval(index, value) - }, - ) + val result = + appendHeartRateSample( + captureId = current.captureId, + observedAt = observedAt, + bpm = measurement.bpm, + sensorContactDetected = measurement.sensorContactDetected, + energyExpended = measurement.energyExpended, + rrIntervals = + measurement.rrIntervals1024.mapIndexed { index, value -> + HeartRateRrInterval(index, value) + }, + ) + if (result is HeartRateMutationResult.Applied && contextKind == HeartRateContextKind.CARDIO) { + runCatching { + updateActiveCardioCalibrationFromMeasurement( + current.captureId, + observedAt, + measurement, + ) + } + } + return result } private fun heartRateContextIdValid(kind: HeartRateContextKind, value: String): Boolean = @@ -2221,6 +2682,7 @@ class TrainlogRepository( artifacts["program-executions"] = buildProgramExecutionsV1Json() artifacts["sleep-diary"] = buildSleepDiaryV1Json() artifacts["heart-rate"] = buildHeartRateV1Json() + artifacts["cardio-calibrations"] = buildCardioCalibrationsV1Json() artifacts["session-timeline"] = buildSessionTimelineV1Json() artifacts } @@ -2746,6 +3208,8 @@ class TrainlogRepository( val normalizedPrefix = normalizeName(query) val selection = mutableListOf() val arguments = mutableListOf() + selection += "e.exercise_id<>?" + arguments += CARDIO_CALIBRATION_EXERCISE_ID if (normalizedPrefix.isNotEmpty()) { selection += "e.normalized_name LIKE ? ESCAPE '\\'" arguments += normalizedPrefix @@ -3078,8 +3542,9 @@ class TrainlogRepository( "SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields," + "e.load_semantics,e.machine_variant,e.machine_provenance,e.scientific_profile_id," + "e.science_state,e.legacy_equipment_id,s.revision_id,s.parent_revision_id,s.legacy_seed " + - "FROM exercises e JOIN exercise_profile_state s ON s.exercise_row_id=e.id ORDER BY e.exercise_id", - null, + "FROM exercises e JOIN exercise_profile_state s ON s.exercise_row_id=e.id " + + "WHERE e.exercise_id<>? ORDER BY e.exercise_id", + arrayOf(CARDIO_CALIBRATION_EXERCISE_ID), ).use { c -> while (c.moveToNext()) { check(c.getType(3)==Cursor.FIELD_TYPE_INTEGER && c.getType(12)==Cursor.FIELD_TYPE_INTEGER && @@ -4876,6 +5341,11 @@ class TrainlogRepository( ) val suppliedExerciseId = item.getString("exercise_id") + if (suppliedExerciseId == CARDIO_CALIBRATION_EXERCISE_ID) { + return PcCatalogImportResult.Invalid( + "Reserved cardio calibration exercise is not owned by the PC catalog.", + ) + } /* CONTRACT: a peer which has not yet consumed the companion * may resend a retired catalogue ID; resolve it before lookup * so the old row can never be resurrected. */ @@ -9862,6 +10332,22 @@ class TrainlogRepository( allowTargetOnly: Boolean = false, ): Boolean { if (!validateSessionPlan(draft)) return false + if ( + allowTargetOnly && + sessionType == SessionType.CARDIO && + draft.exercise.exerciseId == CARDIO_CALIBRATION_EXERCISE_ID && + draft.exercise.recordingMode == RecordingMode.CONTINUOUS && + draft.exercise.trackingMode == TrackingMode.DURATION && + draft.exercise.dataFields == 0 && + draft.plan == null && + draft.sets.isEmpty() && + draft.continuousDurationSeconds == 0 && + draft.speedKmh == null && + draft.distanceKm == null && + draft.maxWeightKg == null + ) { + return true + } val maxWeight = draft.maxWeightKg if (maxWeight != null) { /* INVARIANT: max is a first-class result owned by the movement @@ -10674,6 +11160,14 @@ private const val MAX_HEART_RATE_RR_PER_SAMPLE = 64 private const val MAX_HEART_RATE_SENSOR_NAME_UTF8_BYTES = 160 private const val MAX_SESSION_TIMELINE_SESSIONS_PER_GENERATION = 256 private const val MAX_SESSION_TIMELINE_EXERCISES_PER_SESSION = 128 +private const val CARDIO_CALIBRATION_PROTOCOL_VERSION = 1 +private const val CARDIO_CALIBRATION_EXERCISE_ID = + "ex_ca1b4a7e-1c2d-4f00-8a11-000000000001" +private const val CARDIO_CALIBRATION_EXERCISE_NAME = "Calibration cardio" +private const val MAX_CARDIO_CALIBRATIONS_PER_GENERATION = 128 +private val CARDIO_CALIBRATION_RECOVERY_OFFSETS = intArrayOf(60, 120, 180) +private const val CARDIO_CALIBRATION_MIN_RECOVERY_SECONDS = 180L +private const val CARDIO_CALIBRATION_RECOVERY_SAMPLE_MAX_LAG_SECONDS = 10L private const val MAX_AI_DRAFT_ENTRIES = 64 private const val MAX_AI_TARGET_SETS = 99 private const val MAX_AI_TARGET_REPS = 999 @@ -10692,7 +11186,7 @@ private class TrainlogDatabaseHelper( appContext, databaseName, null, - 29, + 30, ) { override fun onConfigure( db: SQLiteDatabase, @@ -10748,6 +11242,8 @@ private class TrainlogDatabaseHelper( createSleepDiaryTables(db) createHeartRateTables(db) createSessionTimelineTables(db) + createCardioCalibrationTables(db) + ensureCardioCalibrationExercise(db) seedEquipment(db) } @@ -11004,6 +11500,16 @@ private class TrainlogDatabaseHelper( ) version = 29 } + if (version < 30 && newVersion >= 30) { + /* WHY: calibration metadata must be versioned separately from the + * authoritative raw heart-rate curve. CONTRACT: v30 adds only the + * calibration profile/recovery/publication domain and one reserved + * continuous exercise identity. INVARIANT: no existing workout, + * heart-rate sample, or session timing is rewritten. */ + createCardioCalibrationTables(db) + ensureCardioCalibrationExercise(db) + version = 30 + } if (version != newVersion) { error( @@ -11142,6 +11648,85 @@ private class TrainlogDatabaseHelper( PRIMARY KEY(generation_id,entry_id))""".trimIndent()) } + private fun createCardioCalibrationTables(db: SQLiteDatabase) { + db.execSQL( + """CREATE TABLE IF NOT EXISTS cardio_calibrations( + calibration_id TEXT PRIMARY KEY, + protocol_version INTEGER NOT NULL CHECK(protocol_version=1), + session_id TEXT NOT NULL UNIQUE, + entry_id TEXT NOT NULL, + phase TEXT NOT NULL + CHECK(phase IN('warmup','progressive','high_effort','recovery','completed','aborted')), + started_at TEXT NOT NULL, + phase_started_at TEXT NOT NULL, + effort_end_at TEXT, + ended_at TEXT, + heart_rate_capture_id TEXT, + observed_peak_bpm INTEGER CHECK(observed_peak_bpm IS NULL OR observed_peak_bpm BETWEEN 0 AND 65535), + active_slot INTEGER UNIQUE CHECK(active_slot IS NULL OR active_slot=1), + acknowledged_at TEXT, + CHECK(calibration_id GLOB 'cal_*'), + CHECK(session_id GLOB 'se_*'), + CHECK(entry_id GLOB 'sxe_*'), + CHECK(heart_rate_capture_id IS NULL OR heart_rate_capture_id GLOB 'hrc_*'), + CHECK((phase IN('completed','aborted'))=(ended_at IS NOT NULL)), + CHECK(phase='aborted' OR + (phase IN('recovery','completed') AND effort_end_at IS NOT NULL) OR + (phase IN('warmup','progressive','high_effort') AND effort_end_at IS NULL)) + )""".trimIndent(), + ) + db.execSQL( + """CREATE TABLE IF NOT EXISTS cardio_calibration_recovery( + calibration_id TEXT NOT NULL + REFERENCES cardio_calibrations(calibration_id) ON DELETE CASCADE, + target_offset_seconds INTEGER NOT NULL CHECK(target_offset_seconds IN(60,120,180)), + observed_at TEXT NOT NULL, + bpm INTEGER NOT NULL CHECK(bpm BETWEEN 0 AND 65535), + PRIMARY KEY(calibration_id,target_offset_seconds) + )""".trimIndent(), + ) + db.execSQL( + """CREATE TABLE IF NOT EXISTS cardio_calibration_generation( + generation_id TEXT NOT NULL + REFERENCES sync_generations(generation_id) ON DELETE RESTRICT, + calibration_id TEXT NOT NULL + REFERENCES cardio_calibrations(calibration_id) ON DELETE RESTRICT, + PRIMARY KEY(generation_id,calibration_id) + )""".trimIndent(), + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS cardio_calibration_session " + + "ON cardio_calibrations(session_id,started_at);", + ) + } + + private fun ensureCardioCalibrationExercise(db: SQLiteDatabase) { + db.execSQL( + """INSERT OR IGNORE INTO exercises( + exercise_id,name,normalized_name,recording_mode,tracking_mode,data_fields + ) VALUES(?,?,?,'continuous','duration',0)""".trimIndent(), + arrayOf( + CARDIO_CALIBRATION_EXERCISE_ID, + CARDIO_CALIBRATION_EXERCISE_NAME, + "calibration cardio", + ), + ) + val valid = + db.rawQuery( + "SELECT name,normalized_name,recording_mode,tracking_mode,data_fields " + + "FROM exercises WHERE exercise_id=?", + arrayOf(CARDIO_CALIBRATION_EXERCISE_ID), + ).use { cursor -> + cursor.moveToFirst() && + cursor.getString(0) == CARDIO_CALIBRATION_EXERCISE_NAME && + cursor.getString(1) == "calibration cardio" && + cursor.getString(2) == "continuous" && + cursor.getString(3) == "duration" && + cursor.getInt(4) == 0 + } + check(valid) { "Reserved cardio calibration exercise conflicts with existing catalogue data." } + } + private fun createHeartRateTables(db: SQLiteDatabase) { db.execSQL( """CREATE TABLE IF NOT EXISTS heart_rate_captures( diff --git a/android/app/src/main/java/com/labfytools/trainlog/model/CardioCalibrationModels.kt b/android/app/src/main/java/com/labfytools/trainlog/model/CardioCalibrationModels.kt new file mode 100644 index 0000000..417a7ad --- /dev/null +++ b/android/app/src/main/java/com/labfytools/trainlog/model/CardioCalibrationModels.kt @@ -0,0 +1,37 @@ +package com.labfytools.trainlog.model + +enum class CardioCalibrationPhase(val wireValue: String) { + WARMUP("warmup"), + PROGRESSIVE("progressive"), + HIGH_EFFORT("high_effort"), + RECOVERY("recovery"), + COMPLETED("completed"), + ABORTED("aborted"); + + companion object { + fun fromWire(value: String): CardioCalibrationPhase = + entries.firstOrNull { it.wireValue == value } + ?: error("Unknown cardio calibration phase: $value") + } +} + +data class CardioCalibrationRecoveryPoint( + val targetOffsetSeconds: Int, + val observedAt: String, + val bpm: Int, +) + +data class CardioCalibrationProfile( + val calibrationId: String, + val protocolVersion: Int, + val sessionId: String, + val entryId: String, + val phase: CardioCalibrationPhase, + val startedAt: String, + val phaseStartedAt: String, + val effortEndAt: String?, + val endedAt: String?, + val heartRateCaptureId: String?, + val observedPeakBpm: Int?, + val recovery: List, +) diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/AndroidAppShell.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/AndroidAppShell.kt index 5bc46c5..12bf575 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/AndroidAppShell.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/AndroidAppShell.kt @@ -69,6 +69,7 @@ fun AndroidAppShell( AppRoute.SessionEditor -> R.string.route_session_editor; AppRoute.SessionGenerator -> R.string.route_session_generator AppRoute.AiSessionDrafts -> R.string.route_ai_drafts; AppRoute.CompletedSessions -> R.string.route_completed_sessions AppRoute.Programs -> R.string.route_programs; is AppRoute.ProgramDetail -> R.string.route_program_detail + AppRoute.CardioCalibration -> R.string.route_cardio_calibration is AppRoute.SessionDetail -> R.string.route_session_detail; is AppRoute.SessionCorrection -> R.string.route_session_correction AppRoute.Exercises -> R.string.nav_exercises; is AppRoute.ExerciseDetail -> R.string.route_exercise_detail is AppRoute.ExerciseEdit -> R.string.route_exercise_edit; is AppRoute.ExerciseCreate -> R.string.route_exercise_create diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/AppNavigation.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/AppNavigation.kt index 67097d4..b355648 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/AppNavigation.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/AppNavigation.kt @@ -24,6 +24,7 @@ sealed interface AppRoute { data object AiSessionDrafts : AppRoute { override val section = AppSection.SESSIONS } data object CompletedSessions : AppRoute { override val section = AppSection.SESSIONS } data object Programs : AppRoute { override val section = AppSection.SESSIONS } + data object CardioCalibration : AppRoute { override val section = AppSection.SESSIONS } data class ProgramDetail(val programId: String) : AppRoute { override val section = AppSection.SESSIONS } data class SessionDetail(val sessionId: String) : AppRoute { override val section = AppSection.SESSIONS } data class SessionCorrection(val sessionId: String) : AppRoute { override val section = AppSection.SESSIONS } diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/CardioCalibrationScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/CardioCalibrationScreen.kt new file mode 100644 index 0000000..f66b319 --- /dev/null +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/CardioCalibrationScreen.kt @@ -0,0 +1,172 @@ +package com.labfytools.trainlog.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import com.labfytools.trainlog.R +import com.labfytools.trainlog.data.TrainlogRepository +import com.labfytools.trainlog.model.CardioCalibrationPhase +import com.labfytools.trainlog.ui.theme.LocalTrainlogColors + +@Composable +fun CardioCalibrationScreen( + repository: TrainlogRepository, + onChanged: () -> Unit, +) { + val strings = localizedContext() + val colors = LocalTrainlogColors.current + var revision by remember { mutableIntStateOf(0) } + var message by remember { mutableStateOf(null) } + val active = remember(revision) { repository.activeCardioCalibration() } + val latest = remember(revision) { + repository.listCompletedCardioCalibrations(1).firstOrNull() + } + + fun apply(result: TrainlogRepository.CardioCalibrationResult) { + message = + when (result) { + is TrainlogRepository.CardioCalibrationResult.Applied -> { + revision++ + onChanged() + null + } + is TrainlogRepository.CardioCalibrationResult.Invalid -> result.message + TrainlogRepository.CardioCalibrationResult.Conflict -> + strings.getString(R.string.calibration_conflict) + is TrainlogRepository.CardioCalibrationResult.DatabaseError -> + result.message + } + } + + fun phaseLabel(phase: CardioCalibrationPhase): String = + strings.getString( + when (phase) { + CardioCalibrationPhase.WARMUP -> R.string.calibration_phase_warmup + CardioCalibrationPhase.PROGRESSIVE -> R.string.calibration_phase_progressive + CardioCalibrationPhase.HIGH_EFFORT -> R.string.calibration_phase_high_effort + CardioCalibrationPhase.RECOVERY -> R.string.calibration_phase_recovery + CardioCalibrationPhase.COMPLETED -> R.string.calibration_phase_completed + CardioCalibrationPhase.ABORTED -> R.string.calibration_phase_aborted + }, + ) + + TrainlogScreen( + strings.getString(R.string.route_cardio_calibration), + scrollKey = "cardio-calibration", + ) { + TrainlogInfo(strings.getString(R.string.calibration_explanation), colors.muted) + + if (active == null) { + TrainlogPrimaryAction( + strings.getString(R.string.calibration_start), + strings.getString(R.string.calibration_start_description), + ) { + apply(repository.startCardioCalibration()) + } + latest?.let { profile -> + TrainlogFrame(strings.getString(R.string.calibration_latest)) { + TrainlogInfo( + strings.getString( + R.string.calibration_peak_value, + profile.observedPeakBpm ?: 0, + ), + colors.accent, + ) + profile.recovery.forEach { point -> + TrainlogInfo( + strings.getString( + R.string.calibration_recovery_value, + point.targetOffsetSeconds / 60, + point.bpm, + ), + colors.muted, + ) + } + } + } + } else { + TrainlogFrame(strings.getString(R.string.calibration_current)) { + TrainlogInfo( + strings.getString( + R.string.calibration_phase_value, + phaseLabel(active.phase), + ), + colors.accent, + ) + active.observedPeakBpm?.let { bpm -> + TrainlogInfo( + strings.getString(R.string.calibration_peak_value, bpm), + colors.warning, + ) + } + active.recovery.forEach { point -> + TrainlogInfo( + strings.getString( + R.string.calibration_recovery_value, + point.targetOffsetSeconds / 60, + point.bpm, + ), + colors.muted, + ) + } + } + + when (active.phase) { + CardioCalibrationPhase.WARMUP -> { + TrainlogPrimaryAction( + strings.getString(R.string.calibration_to_progressive), + strings.getString(R.string.calibration_progressive_description), + ) { + apply(repository.advanceCardioCalibrationPhase()) + } + } + CardioCalibrationPhase.PROGRESSIVE -> { + TrainlogPrimaryAction( + strings.getString(R.string.calibration_to_high_effort), + strings.getString(R.string.calibration_high_effort_description), + ) { + apply(repository.advanceCardioCalibrationPhase()) + } + } + CardioCalibrationPhase.HIGH_EFFORT -> { + TrainlogPrimaryAction( + strings.getString(R.string.calibration_end_effort), + strings.getString(R.string.calibration_end_effort_description), + ) { + apply(repository.markCardioCalibrationEffortEnd()) + } + } + CardioCalibrationPhase.RECOVERY -> { + TrainlogPrimaryAction( + strings.getString(R.string.calibration_finish), + strings.getString(R.string.calibration_finish_description), + ) { + apply(repository.finishCardioCalibration()) + } + } + CardioCalibrationPhase.COMPLETED, + CardioCalibrationPhase.ABORTED -> Unit + } + + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + TrainlogButton( + strings.getString(R.string.calibration_abort), + { apply(repository.abortCardioCalibration()) }, + style = TrainlogButtonStyle.DESTRUCTIVE, + ) + } + } + + message?.let { TrainlogInfo(it, colors.error) } + } +} diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/SectionScreens.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/SectionScreens.kt index 4e1349d..9c29090 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/SectionScreens.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/SectionScreens.kt @@ -58,6 +58,7 @@ fun SessionsHub( onDrafts: () -> Unit, onHistory: () -> Unit, onPrograms: () -> Unit = {}, + onCardioCalibration: () -> Unit = {}, ) { val colors = LocalTrainlogColors.current val strings = localizedContext() @@ -77,6 +78,13 @@ fun SessionsHub( Modifier.weight(1f).fillMaxHeight().testTag("sessions-drafts-action")) } } + TrainlogButton( + strings.getString(R.string.route_cardio_calibration), + onCardioCalibration, + Modifier.fillMaxWidth().testTag("sessions-cardio-calibration-action"), + style = TrainlogButtonStyle.SUCCESS, + ) + Spacer(Modifier.height(10.dp)) TrainlogButton(strings.getString(R.string.route_completed_sessions), onHistory, Modifier.fillMaxWidth().testTag("sessions-history-action")) Spacer(Modifier.height(16.dp).testTag("sessions-programs-spacing")) diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt index f8aa3b4..96f55f0 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt @@ -103,8 +103,16 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S }, { open(AppRoute.BodyMeasurements) }, { open(AppRoute.Sync) }, ) - AppRoute.Sessions -> SessionsHub(activeDraft, pendingAiDraftCount, { open(AppRoute.SessionEditor) }, ::openManualSession, - { open(AppRoute.AiSessionDrafts) }, { open(AppRoute.CompletedSessions) }, { open(AppRoute.Programs) }) + AppRoute.Sessions -> SessionsHub( + activeDraft, + pendingAiDraftCount, + { open(AppRoute.SessionEditor) }, + ::openManualSession, + { open(AppRoute.AiSessionDrafts) }, + { open(AppRoute.CompletedSessions) }, + { open(AppRoute.Programs) }, + { open(AppRoute.CardioCalibration) }, + ) AppRoute.SessionEditor -> SessionScreen( repository, catalogRevision, @@ -137,6 +145,10 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S ) AppRoute.CompletedSessions -> HistoryScreen(repository, { back() }) { open(AppRoute.SessionDetail(it)) } AppRoute.Programs -> ProgramsScreen(repository) { open(AppRoute.ProgramDetail(it)) } + AppRoute.CardioCalibration -> CardioCalibrationScreen(repository) { + draftRevision++ + exportSnapshot() + } is AppRoute.ProgramDetail -> ProgramDetailScreen(repository, route.programId) { draftRevision++ open(AppRoute.SessionEditor) diff --git a/android/app/src/main/res/values-en/strings.xml b/android/app/src/main/res/values-en/strings.xml index 913d215..3d1342d 100644 --- a/android/app/src/main/res/values-en/strings.xml +++ b/android/app/src/main/res/values-en/strings.xml @@ -560,4 +560,29 @@ The night changed; reload before trying again. Unable to record the action. Cardio + Cardio calibration + This session measures your current heart-rate response. Effort increases progressively and you can stop immediately at any time. The result is stored as an observed heart-rate peak, not as a medical maximum heart rate. + Start calibration + Create a dedicated cardio session, start heart-rate capture and begin with warm-up. + Latest calibration + Calibration in progress + Phase: %1$s + Warm-up + Progressive effort + High effort + Recovery + Completed + Stopped + Observed peak: %1$d BPM + Recovery +%1$d min: %2$d BPM + Start progressive effort + Increase effort progressively without trying to beat a previous value. + Move to high effort + Continue only while you choose to tolerate the effort. The next button ends the effort phase immediately. + Stop effort + Mark the end of effort and begin measured recovery. + Finish calibration + Close recovery and save the calibration profile. + Stop calibration + Another session or calibration is already active. diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index d7c468b..00b6c40 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -570,4 +570,29 @@ La nuit a changé ; rechargez avant de recommencer. Enregistrement impossible. Cardio + Calibration cardio + Cette séance mesure votre réponse cardiaque actuelle. L’effort augmente progressivement et vous pouvez arrêter immédiatement à tout moment. Le résultat est enregistré comme un pic de fréquence cardiaque observé, pas comme une FC max médicale. + Démarrer la calibration + Créer une séance cardio dédiée, démarrer la capture cardiaque et commencer par l’échauffement. + Dernière calibration + Calibration en cours + Phase : %1$s + Échauffement + Montée progressive + Effort élevé + Récupération + Terminée + Arrêtée + Pic observé : %1$d BPM + Récupération +%1$d min : %2$d BPM + Commencer la montée progressive + Augmenter progressivement l’effort sans chercher à battre une ancienne valeur. + Passer à l’effort élevé + Continuer seulement tant que vous choisissez de tolérer l’effort. Le bouton suivant arrête immédiatement la phase d’effort. + Arrêter l’effort + Marquer la fin de l’effort et commencer la récupération mesurée. + Terminer la calibration + 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. 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 0cd085e..3e35074 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(29, scalar(db, "PRAGMA user_version")) + assertEquals(30, scalar(db, "PRAGMA user_version")) for (table in listOf("session_exercises", "draft_session_exercises")) { assertEquals(6, scalar(db, "SELECT COUNT(*) FROM pragma_table_info('$table') WHERE name IN ('load_mode','rest_seconds','target_sets','target_reps','target_duration_seconds','target_weight_kg')")) assertEquals(0, scalar(db, "SELECT COUNT(*) FROM $table WHERE load_mode<>'none' OR rest_seconds<>0 OR target_sets IS NOT NULL OR target_reps IS NOT NULL OR target_duration_seconds IS NOT NULL OR target_weight_kg IS NOT NULL")) 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 28a1608..d93914c 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(29, scalar(db, "PRAGMA user_version")) + assertEquals(30, scalar(db, "PRAGMA user_version")) /* v13 intentionally adds the frozen machine identities and * exercise-owned metadata; all non-exercise facts stay exact. */ val after = snapshot(db).filterKeys { it !in setOf("exercise_aliases", "exercise_profile_state", "exercise_profile_revisions") } 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 74e34b5..1dbb9b9 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(29, scalar(db, "PRAGMA user_version")) + assertEquals(30, scalar(db, "PRAGMA user_version")) assertEquals(before, listOf( scalar(db, "SELECT COUNT(*) FROM sessions"), scalar(db, "SELECT COUNT(*) FROM performed_sets"), 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 db676cf..c75d9f4 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(29, scalar(db, "PRAGMA user_version")) + assertEquals(30, scalar(db, "PRAGMA user_version")) assertEquals(0, scalar(db, "SELECT COUNT(*) FROM sessions WHERE source_program_id IS NOT NULL")) assertEquals("ok", text(db, "PRAGMA integrity_check")) db.rawQuery("PRAGMA foreign_key_check", null).use { cursor -> 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 dff83d1..94a770f 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(29, it.version) } + ).use { assertEquals(30, 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 674c923..d93d13a 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(29, it.version) } + ).use { assertEquals(30, 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 0074618..4d0de17 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(29, db.version) + assertEquals(30, 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 53843e0..6181201 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(29, db.version) + assertEquals(30, 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 new file mode 100644 index 0000000..e16a608 --- /dev/null +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV30CardioCalibrationMigrationTest.kt @@ -0,0 +1,69 @@ +package com.labfytools.trainlog.data + +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import androidx.test.core.app.ApplicationProvider +import java.util.UUID +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class AndroidV30CardioCalibrationMigrationTest { + private val context: Context = ApplicationProvider.getApplicationContext() + + @Test + fun v29AddsEmptyCalibrationDomainAndReservedExercise() { + val name = "calibration-v29-" + UUID.randomUUID() + ".db" + var repository = TrainlogRepository(context, name) + try { + repository.listExercises() + repository.close() + SQLiteDatabase.openDatabase( + context.getDatabasePath(name).path, + null, + SQLiteDatabase.OPEN_READWRITE, + ).use { db -> + db.execSQL("DROP TABLE cardio_calibration_generation") + db.execSQL("DROP TABLE cardio_calibration_recovery") + db.execSQL("DROP TABLE cardio_calibrations") + db.version = 29 + } + + repository = TrainlogRepository(context, name) + assertTrue(repository.listExercises().none { it.name == "Calibration cardio" }) + SQLiteDatabase.openDatabase( + context.getDatabasePath(name).path, + null, + SQLiteDatabase.OPEN_READONLY, + ).use { db -> + assertEquals(30, db.version) + db.rawQuery( + "SELECT COUNT(*) FROM exercises WHERE " + + "exercise_id='ex_ca1b4a7e-1c2d-4f00-8a11-000000000001' " + + "AND name='Calibration cardio'", + null, + ).use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals(1, cursor.getInt(0)) + } + db.rawQuery( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN(" + + "'cardio_calibrations','cardio_calibration_recovery'," + + "'cardio_calibration_generation')", + null, + ).use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals(3, cursor.getInt(0)) + } + } + } finally { + repository.close() + context.deleteDatabase(name) + } + } +} 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 e86da61..56bf79c 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(29, cursor.getInt(0)) + assertTrue(cursor.moveToFirst()); assertEquals(30, cursor.getInt(0)) } db.rawQuery("SELECT id FROM exercises", null).use { cursor -> assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0)) 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 572b404..32d6bc7 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(29, it.version) } + .use { assertEquals(30, it.version) } } finally { repository.close() context.deleteDatabase(name) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/CardioCalibrationRepositoryTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/CardioCalibrationRepositoryTest.kt new file mode 100644 index 0000000..8a65716 --- /dev/null +++ b/android/app/src/test/java/com/labfytools/trainlog/data/CardioCalibrationRepositoryTest.kt @@ -0,0 +1,187 @@ +package com.labfytools.trainlog.data + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.labfytools.trainlog.model.CardioCalibrationPhase +import com.labfytools.trainlog.model.SessionType +import java.util.UUID +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class CardioCalibrationRepositoryTest { + private val context: Context = ApplicationProvider.getApplicationContext() + + private fun measurement(bpm: Int) = + ParsedHeartRateMeasurement( + bpm = bpm, + sensorContactDetected = null, + energyExpended = null, + rrIntervals1024 = listOf(1024), + ) + + @Test + fun calibrationTracksObservedPeakRecoveryAndFinalizesRealCardioSession() { + val name = "calibration-" + UUID.randomUUID() + ".db" + val repository = TrainlogRepository(context, name) + try { + val started = + repository.startCardioCalibration("2026-09-23T10:00:00+02:00") + as TrainlogRepository.CardioCalibrationResult.Applied + assertEquals(CardioCalibrationPhase.WARMUP, started.profile.phase) + + repository.recordLiveHeartRateForActiveSession( + "CYCPLUS H2", + "2026-09-23T10:00:10+02:00", + measurement(80), + ) + assertEquals( + CardioCalibrationPhase.PROGRESSIVE, + (repository.advanceCardioCalibrationPhase("2026-09-23T10:01:00+02:00") + as TrainlogRepository.CardioCalibrationResult.Applied).profile.phase, + ) + repository.recordLiveHeartRateForActiveSession( + "CYCPLUS H2", + "2026-09-23T10:01:20+02:00", + measurement(112), + ) + assertEquals( + CardioCalibrationPhase.HIGH_EFFORT, + (repository.advanceCardioCalibrationPhase("2026-09-23T10:02:00+02:00") + as TrainlogRepository.CardioCalibrationResult.Applied).profile.phase, + ) + repository.recordLiveHeartRateForActiveSession( + "CYCPLUS H2", + "2026-09-23T10:02:30+02:00", + measurement(150), + ) + repository.recordLiveHeartRateForActiveSession( + "CYCPLUS H2", + "2026-09-23T10:03:00+02:00", + measurement(160), + ) + + val recovery = + repository.markCardioCalibrationEffortEnd("2026-09-23T10:03:01+02:00") + as TrainlogRepository.CardioCalibrationResult.Applied + assertEquals(CardioCalibrationPhase.RECOVERY, recovery.profile.phase) + assertEquals(160, recovery.profile.observedPeakBpm) + assertTrue(recovery.profile.heartRateCaptureId != null) + + repository.recordLiveHeartRateForActiveSession( + "CYCPLUS H2", + "2026-09-23T10:04:01+02:00", + measurement(140), + ) + repository.recordLiveHeartRateForActiveSession( + "CYCPLUS H2", + "2026-09-23T10:05:02+02:00", + measurement(120), + ) + repository.recordLiveHeartRateForActiveSession( + "CYCPLUS H2", + "2026-09-23T10:06:01+02:00", + measurement(100), + ) + + val completed = + repository.finishCardioCalibration("2026-09-23T10:06:10+02:00") + as TrainlogRepository.CardioCalibrationResult.Applied + assertEquals(CardioCalibrationPhase.COMPLETED, completed.profile.phase) + assertEquals( + listOf(60 to 140, 120 to 120, 180 to 100), + completed.profile.recovery.map { it.targetOffsetSeconds to it.bpm }, + ) + assertNull(repository.activeCardioCalibration()) + assertNull(repository.activeHeartRateCapture()) + + val session = repository.listSessions().single() + assertEquals(SessionType.CARDIO, session.sessionType) + val detail = checkNotNull(repository.getSessionDetail(session.sessionId)) + assertEquals(370, detail.exercises.single().continuousDurationSeconds) + assertEquals(1, repository.listCompletedCardioCalibrations().size) + } finally { + repository.close() + context.deleteDatabase(name) + } + } + + @Test + fun recoveryDoesNotInventLateReferencePointsAndCannotFinishTooEarly() { + val name = "calibration-recovery-" + UUID.randomUUID() + ".db" + val repository = TrainlogRepository(context, name) + try { + repository.startCardioCalibration("2026-09-23T12:00:00+02:00") + repository.recordLiveHeartRateForActiveSession( + "CYCPLUS H2", + "2026-09-23T12:00:10+02:00", + measurement(80), + ) + repository.advanceCardioCalibrationPhase("2026-09-23T12:01:00+02:00") + repository.advanceCardioCalibrationPhase("2026-09-23T12:02:00+02:00") + repository.recordLiveHeartRateForActiveSession( + "CYCPLUS H2", + "2026-09-23T12:02:30+02:00", + measurement(155), + ) + repository.markCardioCalibrationEffortEnd("2026-09-23T12:03:00+02:00") + + repository.recordLiveHeartRateForActiveSession( + "CYCPLUS H2", + "2026-09-23T12:05:05+02:00", + measurement(118), + ) + val duringRecovery = checkNotNull(repository.activeCardioCalibration()) + assertEquals( + listOf(120 to 118), + duringRecovery.recovery.map { it.targetOffsetSeconds to it.bpm }, + ) + + val tooEarly = + repository.finishCardioCalibration("2026-09-23T12:05:59+02:00") + assertTrue(tooEarly is TrainlogRepository.CardioCalibrationResult.Invalid) + assertTrue(repository.activeCardioCalibration() != null) + + repository.recordLiveHeartRateForActiveSession( + "CYCPLUS H2", + "2026-09-23T12:06:03+02:00", + measurement(105), + ) + val completed = + repository.finishCardioCalibration("2026-09-23T12:06:05+02:00") + as TrainlogRepository.CardioCalibrationResult.Applied + assertEquals( + listOf(120 to 118, 180 to 105), + completed.profile.recovery.map { it.targetOffsetSeconds to it.bpm }, + ) + } finally { + repository.close() + context.deleteDatabase(name) + } + } + + @Test + fun abortBeforeEffortEndNeverCreatesCompletedReference() { + val name = "calibration-abort-" + UUID.randomUUID() + ".db" + val repository = TrainlogRepository(context, name) + try { + repository.startCardioCalibration("2026-09-23T11:00:00+02:00") + val aborted = + repository.abortCardioCalibration("2026-09-23T11:01:00+02:00") + as TrainlogRepository.CardioCalibrationResult.Applied + assertEquals(CardioCalibrationPhase.ABORTED, aborted.profile.phase) + assertNull(aborted.profile.effortEndAt) + assertTrue(repository.loadActiveSessionDraft() is ActiveDraftLoadResult.None) + assertTrue(repository.listCompletedCardioCalibrations().isEmpty()) + } finally { + repository.close() + context.deleteDatabase(name) + } + } +} 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 1edfaf0..14e13ef 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("29", scalarText(name, "PRAGMA user_version")) + assertEquals("30", 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 0b710df..12a6bc2 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(29, it.getInt(0)) + assertEquals(30, 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 6e4cad2..c76e937 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(29, cursor.getInt(0)) + assertEquals(30, cursor.getInt(0)) } db.rawQuery( "SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " + @@ -2387,7 +2387,7 @@ class TrainlogRepositoryDraftTest { ).use { db -> db.rawQuery("PRAGMA user_version;", null).use { cursor -> assertTrue(cursor.moveToFirst()) - assertEquals(29, cursor.getInt(0)) + assertEquals(30, cursor.getInt(0)) } db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor -> assertTrue(cursor.moveToFirst()) diff --git a/docs/current_state.md b/docs/current_state.md index 6b83d24..fe7c2c8 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 v32; canonical `session_kind` adds true cardio sessions while frozen legacy `session_type` remains training/max_test | -| Android SQLite | schema v29; canonical `session_kind` adds true cardio sessions while legacy draft/history codecs remain training/max_test | +| Desktop SQLite | schema v33; versioned cardio calibration profiles/recovery are additive on top of canonical cardio session kind | +| Android SQLite | schema v30; Android owns active/completed cardio calibration profiles in addition to cardio sessions and HR capture | | Mobile snapshot | V3 active; V1/V2 readable legacy inputs; explicit V4 codec staged, not selected by transport | | Desktop terminal backend | Notcurses only | | Trainlog product version | `0.1.5` development, synchronized across Android and desktop; latest stable release: `v0.1.4` | @@ -153,7 +153,8 @@ parallel implementation of its rules. | Training timeline | `TRAINLOG_CARDIO_TRAINING_TIMELINE_V1=PASS`: real session bounds reuse canonical lifecycle timestamps; exercise start/end are explicit user actions; live BPM samples snapshot the active stable `entry_id`; session finalization refuses an unclosed exercise; timeline sync is Android→desktop idempotent | | Sleep Android one-tap | `TRAINLOG_SLEEP_ANDROID_ONE_TAP_V1=SOFTWARE_PASS / DEVICE_VISUAL_PENDING`: existing Sleep Diary V1 now has one-tap Couché / medication / Réveil / Levé, guarded 10 s undo, full correction route and sleep-entry-owned cardio capture; vc41 installed without data clear, but final visual device review was blocked by the phone lock screen | | Cardio session type | `TRAINLOG_CARDIO_SESSION_TYPE_V1=PASS`: Android v29 + desktop v32 use canonical `session_kind=cardio`; frozen mobile/history and draft formats keep legacy training/max_test; dedicated `trainlog-cardio-sessions` V1 syncs completed cardio sessions Android→desktop idempotently | -| Current operational cursor | `TRAINLOG_CARDIO_CALIBRATION_V1`: add the first versioned Calibration cardio exercise, observed peak BPM and recovery timeline without claiming a physiological maximum | +| Cardio calibration | `TRAINLOG_CARDIO_CALIBRATION_V1=PASS`: reserved system Calibration cardio exercise, real measured observed peak, ≥3 min recovery with bounded +1/+2/+3 min factual samples, immutable profile and Android→desktop calibration companion | +| Current operational cursor | `TRAINLOG_CARDIO_BPM_GUIDANCE_V1`: define phase targets, hysteresis, persistence and fresh-signal-only ACCÉLÈRE / MAINTIENS / RALENTIS guidance | | Complete synchronization gap contract | Frozen dependency contract; operational USB/Drive slices required by v0.1.2 are delivered | | Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` | | Synchronization characterization | `TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` | diff --git a/docs/database.md b/docs/database.md index fdc52c8..601f1a7 100644 --- a/docs/database.md +++ b/docs/database.md @@ -1,5 +1,13 @@ # Desktop database +## Schema v33: synchronized Cardio calibration V1 + +Schema v33 additively stores completed Android-owned Cardio calibration profiles. `cardio_calibrations` is keyed by stable `calibration_id` and retains protocol version, canonical cardio `session_id`, stable occurrence `entry_id`, exact calibration/effort/end timestamps, the synchronized heart-rate `capture_id`, observed peak BPM and import time. + +`cardio_calibration_recovery` stores only factual recovery samples for the defined +60, +120 and +180 second references. The source curve remains Heart Rate V1; the calibration importer verifies that the observed peak and every recovery `(observed_at, bpm)` pair exist in that raw capture. It never derives, interpolates or rewrites HR samples. + +Android owns the active calibration lifecycle. Desktop stores only completed profiles. A later calibration creates a new stable profile and never rewrites an older session or reference. + ## Schema v32: canonical cardio session kind Schema v32 adds the product-owned `sessions.session_kind` column with the diff --git a/docs/roadmap.md b/docs/roadmap.md index b346b7b..2ccd5da 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_CALIBRATION_V1 +CURRENT_OPERATIONAL_CURSOR=TRAINLOG_CARDIO_BPM_GUIDANCE_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 872e88b..854cb9b 100644 --- a/docs/sync_exchange.md +++ b/docs/sync_exchange.md @@ -1127,7 +1127,15 @@ facts are applied. Programs, manual preparations, AI execution drafts, mobile V3/V4 and `TRAINLOG_FORMAT_V1` are not widened by this companion. -## 22. Legacy active-protocol limitations +## 22. Cardio calibration companion V1 + +`trainlog-cardio-calibrations` V1 is an optional Android→desktop full-generation participant containing only completed calibration profiles. It references an already synchronized cardio session, its reserved stable occurrence, and an immutable stopped Heart Rate V1 capture. + +Before import, desktop proves that the referenced session/occurrence and HR capture already exist and match. The declared observed peak must equal a BPM actually present in that capture, and each recovery point must match an exact raw `(observed_at, bpm)` sample. Exact replay is idempotent; stable identity reuse with different metadata or recovery facts is rejected. + +Android generation membership is retained until a correlated consumed ACK. Acknowledged profiles remain local history and are not ordinarily republished. No calibration artifact claims a physiological maximum: the persisted value is the highest BPM actually observed in that calibration capture before recovery. + +## 23. Legacy active-protocol limitations The Web end-to-end tranche adds an explicit opt-in application orchestrator. `POST /api/v1/sync` admits one correlated run and `GET /api/v1/sync/status` is diff --git a/docs/tests.md b/docs/tests.md index fa445ef..bf551b6 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -1798,3 +1798,13 @@ The 2026-09-23 closeout passes the complete native Meson inventory: **106/106 passed, 0 failed**. The complete Android JVM inventory reports **277 tests: 272 passed, five skipped, zero failures/errors**; `assembleDebug` also succeeds. The five skips remain optional historical/external fixture gates. + +## Cardio calibration V1 validation + +`TRAINLOG_CARDIO_CALIBRATION_V1=PASS` covers the Android v29→v30 and desktop v32→v33 additive migrations, reserved system exercise ownership, real cardio session/timeline/HR capture integration, observed peak tracking, recovery, exact replay and generation synchronization. + +The calibration system exercise keeps a stable `exercise_id` for occurrence foreign keys but is deliberately absent from the ordinary exercise catalogue, BODY ZONES, profile-state and PC-catalog companions. This preserves existing exercise-domain invariants while the dedicated cardio session/calibration companions own the system occurrence. + +Recovery cannot complete before three minutes after effort end. The +60/+120/+180 second references accept only a real HR sample arriving from the target instant through ten seconds after it. A missing sensor interval remains missing rather than being populated by a later reconnect. + +The desktop importer independently rejects a peak not supported by the raw HR capture and rejects a recovery point that is not an exact raw sample. The 2026-09-23 closeout passes the complete native inventory **108/108** and the complete Android JVM inventory **281 tests: 276 passed, five skipped, zero failures/errors**; `assembleDebug` also succeeds. diff --git a/tests/test_cardio_calibration_exchange.py b/tests/test_cardio_calibration_exchange.py new file mode 100644 index 0000000..beb9846 --- /dev/null +++ b/tests/test_cardio_calibration_exchange.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 + +import subprocess +import sys +import tempfile +import unittest +from copy import deepcopy +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "tools")) + +import cardio_calibration_exchange as calibration +from trainlog_sqlite import connect_database + + +CAL = "cal_11111111-1111-4111-8111-111111111111" +SESSION = "se_22222222-2222-4222-8222-222222222222" +ENTRY = "sxe_33333333-3333-4333-8333-333333333333" +CAPTURE = "hrc_44444444-4444-4444-8444-444444444444" +EXERCISE = "ex_ca1b4a7e-1c2d-4f00-8a11-000000000001" + + +def document() -> dict: + return { + "format": "trainlog-cardio-calibrations", + "version": 1, + "generated_at": "2026-09-23T10:07:00+02:00", + "calibrations": [ + { + "calibration_id": CAL, + "protocol_version": 1, + "session_id": SESSION, + "entry_id": ENTRY, + "started_at": "2026-09-23T10:00:00+02:00", + "effort_end_at": "2026-09-23T10:03:01+02:00", + "ended_at": "2026-09-23T10:06:10+02:00", + "heart_rate_capture_id": CAPTURE, + "observed_peak_bpm": 160, + "recovery": [ + { + "target_offset_seconds": 60, + "observed_at": "2026-09-23T10:04:01+02:00", + "bpm": 140, + }, + { + "target_offset_seconds": 120, + "observed_at": "2026-09-23T10:05:02+02:00", + "bpm": 120, + }, + { + "target_offset_seconds": 180, + "observed_at": "2026-09-23T10:06:01+02:00", + "bpm": 100, + }, + ], + } + ], + } + + +class CardioCalibrationExchangeTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.db_path = Path(self.temp.name) / "trainlog.sqlite" + fixture = ROOT / "build/tui/sync-generation-fixture" + subprocess.run([str(fixture), str(self.db_path)], check=True, capture_output=True) + self.db = connect_database(self.db_path) + self.db.execute( + "INSERT INTO exercises(exercise_id,name,normalized_name,recording_mode,tracking_mode,data_fields) " + "VALUES(?,?,?,?,?,?)", + (EXERCISE, "Calibration cardio", "calibration cardio", "continuous", "duration", 0), + ) + exercise_row = self.db.execute( + "SELECT id FROM exercises WHERE exercise_id=?", (EXERCISE,) + ).fetchone()[0] + self.db.execute( + "INSERT INTO sessions(session_id,started_at,ended_at,session_type,session_kind) " + "VALUES(?,?,?,?,?)", + ( + SESSION, + "2026-09-23T10:00:00+02:00", + "2026-09-23T10:06:10+02:00", + "training", + "cardio", + ), + ) + session_row = self.db.execute( + "SELECT id FROM sessions WHERE session_id=?", (SESSION,) + ).fetchone()[0] + self.db.execute( + "INSERT INTO session_exercises(" + "entry_id,session_row_id,exercise_row_id,recording_mode,tracking_mode," + "data_fields,position,load_mode,rest_seconds) VALUES(?,?,?,?,?,?,?,?,?)", + (ENTRY, session_row, exercise_row, "continuous", "duration", 0, 0, "none", 0), + ) + self.db.execute( + "INSERT INTO heart_rate_captures(" + "capture_id,context_kind,context_id,started_at,ended_at,sensor_name,imported_at" + ") VALUES(?,?,?,?,?,?,?)", + ( + CAPTURE, + "cardio", + SESSION, + "2026-09-23T10:00:00+02:00", + "2026-09-23T10:06:10+02:00", + "CYCPLUS H2", + "2026-09-23T10:07:00+02:00", + ), + ) + samples = [ + ("2026-09-23T10:00:10+02:00", 80), + ("2026-09-23T10:01:20+02:00", 112), + ("2026-09-23T10:02:30+02:00", 150), + ("2026-09-23T10:03:00+02:00", 160), + ("2026-09-23T10:04:01+02:00", 140), + ("2026-09-23T10:05:02+02:00", 120), + ("2026-09-23T10:06:01+02:00", 100), + ] + self.db.executemany( + "INSERT INTO heart_rate_samples(" + "capture_id,sequence,observed_at,bpm,exercise_entry_id," + "sensor_contact_detected,energy_expended) VALUES(?,?,?,?,?,?,?)", + [ + (CAPTURE, index, observed_at, bpm, ENTRY, None, None) + for index, (observed_at, bpm) in enumerate(samples) + ], + ) + self.db.commit() + + def tearDown(self): + self.db.close() + self.temp.cleanup() + + def test_import_and_exact_replay_are_factual_and_idempotent(self): + payload = calibration.validate(document()) + self.assertEqual((1, 0), calibration.apply(self.db, payload)) + self.assertEqual((0, 1), calibration.apply(self.db, payload)) + self.assertEqual( + (1, SESSION, CAPTURE, 160), + tuple( + self.db.execute( + "SELECT protocol_version,session_id,heart_rate_capture_id,observed_peak_bpm " + "FROM cardio_calibrations WHERE calibration_id=?", + (CAL,), + ).fetchone() + ), + ) + self.assertEqual( + [(60, 140), (120, 120), (180, 100)], + [ + tuple(row) + for row in self.db.execute( + "SELECT target_offset_seconds,bpm FROM cardio_calibration_recovery " + "WHERE calibration_id=? ORDER BY target_offset_seconds", + (CAL,), + ) + ], + ) + + def test_rejects_peak_not_supported_by_raw_capture(self): + changed = deepcopy(document()) + changed["calibrations"][0]["observed_peak_bpm"] = 159 + with self.assertRaisesRegex(ValueError, "peak BPM"): + calibration.apply(self.db, calibration.validate(changed)) + + def test_rejects_recovery_point_not_present_in_raw_capture(self): + changed = deepcopy(document()) + changed["calibrations"][0]["recovery"][0]["bpm"] = 139 + with self.assertRaisesRegex(ValueError, "not a raw heart-rate sample"): + calibration.apply(self.db, calibration.validate(changed)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_sync_deployment_tools.py b/tests/test_sync_deployment_tools.py index 1c4b630..9a2b9be 100644 --- a/tests/test_sync_deployment_tools.py +++ b/tests/test_sync_deployment_tools.py @@ -66,13 +66,14 @@ class DeploymentToolsTest(unittest.TestCase): self.assertEqual(result.returncode, 0, result.stderr) inventory = json.loads((output / "candidate-inventory.json").read_text()) self.assertEqual(inventory["product_version"], "0.1.5") - self.assertEqual(inventory["desktop_schema"], 32) - self.assertEqual(inventory["android_schema"], 29) + self.assertEqual(inventory["desktop_schema"], 33) + self.assertEqual(inventory["android_schema"], 30) self.assertIn("session-preparations-v2", inventory["protocols"]) self.assertIn("programs-v1", inventory["protocols"]) self.assertIn("program-executions-v1", inventory["protocols"]) self.assertIn("sleep-diary-v1", inventory["protocols"]) self.assertIn("cardio-sessions-v1", inventory["protocols"]) + self.assertIn("cardio-calibrations-v1", inventory["protocols"]) self.assertIn("heart-rate-v1", inventory["protocols"]) self.assertIn("session-timeline-v1", inventory["protocols"]) self.assertIn("bluetooth-files-v1", inventory["protocols"]) diff --git a/tools/cardio_calibration_exchange.py b/tools/cardio_calibration_exchange.py new file mode 100644 index 0000000..d888d7c --- /dev/null +++ b/tools/cardio_calibration_exchange.py @@ -0,0 +1,282 @@ +#!/usr/bin/env python3 +"""Validate/import completed Android cardio calibration profiles V1.""" + +import argparse +import json +import re +import sqlite3 +from datetime import datetime, timedelta +from pathlib import Path + +from trainlog_sqlite import connect_database + +FORMAT = "trainlog-cardio-calibrations" +VERSION = 1 +MAX_BYTES = 4 * 1024 * 1024 +MAX_CALIBRATIONS = 128 +UUID4 = r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}" +CALIBRATION_ID = re.compile(rf"^cal_{UUID4}$") +SESSION_ID = re.compile(rf"^se_{UUID4}$") +ENTRY_ID = re.compile(rf"^sxe_{UUID4}$") +CAPTURE_ID = re.compile(rf"^hrc_{UUID4}$") +CALIBRATION_EXERCISE_ID = "ex_ca1b4a7e-1c2d-4f00-8a11-000000000001" +RECOVERY_OFFSETS = (60, 120, 180) + + +class CardioCalibrationError(ValueError): + pass + + +def fail(message: str) -> None: + raise CardioCalibrationError(message) + + +def unique_object(pairs): + result = {} + for key, value in pairs: + if key in result: + fail("duplicate JSON field: " + key) + result[key] = value + return result + + +def timestamp(value: object) -> tuple[str, datetime]: + if not isinstance(value, str) or re.search(r"(?:Z|[+-][0-9]{2}:[0-9]{2})$", value) is None: + fail("invalid cardio calibration timestamp") + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as error: + raise CardioCalibrationError("invalid cardio calibration timestamp") from error + if parsed.utcoffset() is None: + fail("cardio calibration timestamp lacks offset") + return value, parsed + + +def validate(root: object) -> dict: + if not isinstance(root, dict) or set(root) != { + "format", "version", "generated_at", "calibrations" + }: + fail("invalid cardio calibration envelope") + if root["format"] != FORMAT or type(root["version"]) is not int or root["version"] != VERSION: + fail("unsupported cardio calibration format") + timestamp(root["generated_at"]) + calibrations = root["calibrations"] + if not isinstance(calibrations, list) or len(calibrations) > MAX_CALIBRATIONS: + fail("cardio calibration bound exceeded") + seen = set() + for item in calibrations: + keys = { + "calibration_id", "protocol_version", "session_id", "entry_id", + "started_at", "effort_end_at", "ended_at", "heart_rate_capture_id", + "observed_peak_bpm", "recovery", + } + if not isinstance(item, dict) or set(item) != keys: + fail("invalid cardio calibration object") + calibration_id = item["calibration_id"] + if ( + not isinstance(calibration_id, str) + or not CALIBRATION_ID.fullmatch(calibration_id) + or calibration_id in seen + ): + fail("invalid or duplicate cardio calibration identity") + seen.add(calibration_id) + if type(item["protocol_version"]) is not int or item["protocol_version"] != 1: + fail("unsupported cardio calibration protocol") + if not isinstance(item["session_id"], str) or not SESSION_ID.fullmatch(item["session_id"]): + fail("invalid cardio calibration session identity") + if not isinstance(item["entry_id"], str) or not ENTRY_ID.fullmatch(item["entry_id"]): + fail("invalid cardio calibration entry identity") + if ( + not isinstance(item["heart_rate_capture_id"], str) + or not CAPTURE_ID.fullmatch(item["heart_rate_capture_id"]) + ): + fail("invalid cardio calibration capture identity") + _, started = timestamp(item["started_at"]) + _, effort_end = timestamp(item["effort_end_at"]) + _, ended = timestamp(item["ended_at"]) + if not started <= effort_end <= ended: + fail("invalid cardio calibration interval") + if type(item["observed_peak_bpm"]) is not int or not 0 <= item["observed_peak_bpm"] <= 65535: + fail("invalid observed peak BPM") + recovery = item["recovery"] + if not isinstance(recovery, list) or len(recovery) > 3: + fail("invalid cardio calibration recovery") + offsets = [] + for point in recovery: + if not isinstance(point, dict) or set(point) != { + "target_offset_seconds", "observed_at", "bpm" + }: + fail("invalid recovery point") + offset = point["target_offset_seconds"] + if type(offset) is not int or offset not in RECOVERY_OFFSETS or offset in offsets: + fail("invalid recovery offset") + offsets.append(offset) + _, observed = timestamp(point["observed_at"]) + if observed < effort_end + timedelta(seconds=offset) or observed > ended: + fail("recovery point outside factual interval") + if type(point["bpm"]) is not int or not 0 <= point["bpm"] <= 65535: + fail("invalid recovery BPM") + if offsets != sorted(offsets): + fail("recovery points must be ordered") + return root + + +def load(path: Path) -> dict: + if path.stat().st_size > MAX_BYTES: + fail("cardio calibration artifact exceeds 4 MiB") + try: + return validate( + json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=unique_object) + ) + except OSError as error: + raise CardioCalibrationError("cannot read cardio calibration artifact") from error + + +def _verify_source_facts(db: sqlite3.Connection, item: dict) -> None: + session = db.execute( + "SELECT id,started_at,ended_at,session_kind FROM sessions WHERE session_id=?", + (item["session_id"],), + ).fetchone() + if session is None or session[3] != "cardio": + fail("calibration session is missing or not cardio") + if session[1] != item["started_at"] or session[2] != item["ended_at"]: + fail("calibration session bounds differ from canonical history") + + occurrence = db.execute( + "SELECT e.exercise_id FROM session_exercises se " + "JOIN exercises e ON e.id=se.exercise_row_id " + "WHERE se.session_row_id=? AND se.entry_id=?", + (session[0], item["entry_id"]), + ).fetchone() + if occurrence is None or occurrence[0] != CALIBRATION_EXERCISE_ID: + fail("calibration occurrence does not match reserved exercise") + + capture = db.execute( + "SELECT context_kind,context_id,started_at,ended_at FROM heart_rate_captures " + "WHERE capture_id=?", + (item["heart_rate_capture_id"],), + ).fetchone() + if ( + capture is None + or capture[0] != "cardio" + or capture[1] != item["session_id"] + or capture[2] != item["started_at"] + or capture[3] != item["ended_at"] + ): + fail("calibration heart-rate capture does not match session") + + _, effort_end = timestamp(item["effort_end_at"]) + samples = db.execute( + "SELECT observed_at,bpm FROM heart_rate_samples WHERE capture_id=? ORDER BY sequence", + (item["heart_rate_capture_id"],), + ).fetchall() + before_effort_end = [ + bpm + for observed_at, bpm in samples + if timestamp(observed_at)[1] <= effort_end + ] + if not before_effort_end or max(before_effort_end) != item["observed_peak_bpm"]: + fail("observed peak BPM does not match raw capture") + + sample_pairs = {(observed_at, bpm) for observed_at, bpm in samples} + for point in item["recovery"]: + if (point["observed_at"], point["bpm"]) not in sample_pairs: + fail("recovery point is not a raw heart-rate sample") + + +def _same(db: sqlite3.Connection, item: dict) -> bool: + row = db.execute( + "SELECT protocol_version,session_id,entry_id,started_at,effort_end_at,ended_at," + "heart_rate_capture_id,observed_peak_bpm FROM cardio_calibrations " + "WHERE calibration_id=?", + (item["calibration_id"],), + ).fetchone() + if row is None: + return False + expected = ( + item["protocol_version"], + item["session_id"], + item["entry_id"], + item["started_at"], + item["effort_end_at"], + item["ended_at"], + item["heart_rate_capture_id"], + item["observed_peak_bpm"], + ) + if tuple(row) != expected: + fail("calibration identity reused with different metadata") + stored = [ + tuple(row) + for row in db.execute( + "SELECT target_offset_seconds,observed_at,bpm FROM cardio_calibration_recovery " + "WHERE calibration_id=? ORDER BY target_offset_seconds", + (item["calibration_id"],), + ) + ] + incoming = [ + (p["target_offset_seconds"], p["observed_at"], p["bpm"]) + for p in item["recovery"] + ] + if stored != incoming: + fail("calibration identity reused with different recovery") + return True + + +def apply(db: sqlite3.Connection, root: dict) -> tuple[int, int]: + applied = unchanged = 0 + for item in root["calibrations"]: + _verify_source_facts(db, item) + if _same(db, item): + unchanged += 1 + continue + db.execute( + "INSERT INTO cardio_calibrations(" + "calibration_id,protocol_version,session_id,entry_id,started_at,effort_end_at," + "ended_at,heart_rate_capture_id,observed_peak_bpm,imported_at" + ") VALUES(?,?,?,?,?,?,?,?,?,?)", + ( + item["calibration_id"], + item["protocol_version"], + item["session_id"], + item["entry_id"], + item["started_at"], + item["effort_end_at"], + item["ended_at"], + item["heart_rate_capture_id"], + item["observed_peak_bpm"], + root["generated_at"], + ), + ) + db.executemany( + "INSERT INTO cardio_calibration_recovery(" + "calibration_id,target_offset_seconds,observed_at,bpm) VALUES(?,?,?,?)", + [ + ( + item["calibration_id"], + point["target_offset_seconds"], + point["observed_at"], + point["bpm"], + ) + for point in item["recovery"] + ], + ) + applied += 1 + return applied, unchanged + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("input", type=Path) + parser.add_argument("--database", required=True, type=Path) + args = parser.parse_args() + root = load(args.input) + with connect_database(args.database) as db: + if db.execute("PRAGMA user_version").fetchone()[0] != 33: + fail("desktop schema v33 required") + db.execute("BEGIN IMMEDIATE") + apply(db, root) + db.commit() + + +if __name__ == "__main__": + main() diff --git a/tools/causal_delete_exchange.py b/tools/causal_delete_exchange.py index 76c22fa..9a7c08f 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): - raise CausalError(f"desktop schema v20-v32 required, found v{version}") + if version not in (20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33): + raise CausalError(f"desktop schema v20-v33 required, found v{version}") def target_snapshot(db: sqlite3.Connection, kind: str, target: str): diff --git a/tools/execution_draft_exchange.py b/tools/execution_draft_exchange.py index 1f3651a..a2374c6 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): - raise LifecycleError(f"desktop schema v19-v32 required, found v{version}") + if version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33): + raise LifecycleError(f"desktop schema v19-v33 required, found v{version}") def import_document(connection, document, own_transaction=True): diff --git a/tools/export_ai_history.py b/tools/export_ai_history.py index e66591f..88264b4 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) +SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33) MEASUREMENT_FIELDS = ( "body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm", "hips_cm", "left_arm_cm", "right_arm_cm", "left_forearm_cm", @@ -125,7 +125,7 @@ def build_export(connection, generated_at): """Build the raw export using only explicitly ordered SELECT statements.""" schema_version = connection.execute("PRAGMA user_version").fetchone()[0] if schema_version not in SUPPORTED_SCHEMA_VERSIONS: - raise ValueError("schema desktop v15-v32 requis") + raise ValueError("schema desktop v15-v33 requis") root = { "format": "TRAINLOG_AI_EXPORT", diff --git a/tools/export_ai_session_drafts.py b/tools/export_ai_session_drafts.py index be6bd08..5d626c1 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) + supported_versions = (18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - raise ValueError("schema desktop v18-v32 requis") + raise ValueError("schema desktop v18-v33 requis") columns = {row[1] for row in connection.execute( "PRAGMA table_info(ai_session_drafts)")} if "published_at" not in columns: diff --git a/tools/export_equipment_associations.py b/tools/export_equipment_associations.py index 3ec9c7f..896f757 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, 33)) + supported_versions = tuple(range(8, 34)) if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions: - raise ValueError("schema desktop v8 à v32 requis") + raise ValueError("schema desktop v8 à v33 requis") known_equipment = load_supplied_equipment_ids(args.catalog) known_equipment.update(row[0] for row in connection.execute( "SELECT equipment_id FROM custom_equipment")) diff --git a/tools/export_equipment_definitions.py b/tools/export_equipment_definitions.py index 2b8662d..f80764d 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, 33)) + supported_versions = tuple(range(8, 34)) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - raise ValueError("schema desktop v8 à v32 requis") + raise ValueError("schema desktop v8 à v33 requis") if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone(): raise ValueError("causal equipment protection requires the staged artifact") equipment = [dict(row) for row in connection.execute( diff --git a/tools/export_exercise_aliases.py b/tools/export_exercise_aliases.py index 816cd7e..3ca21b0 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): - raise ValueError("schema desktop v12-v32 requis") + 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33): + raise ValueError("schema desktop v12-v33 requis") rows = con.execute( "SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases " "ORDER BY source_exercise_id COLLATE BINARY" diff --git a/tools/export_exercise_body_zones.py b/tools/export_exercise_body_zones.py index a4c4644..15a580c 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, 33)) + supported_versions = tuple(range(11, 34)) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - raise ValueError("schema desktop v11-v32 requis") + raise ValueError("schema desktop v11-v33 requis") if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone(): raise ValueError("causal BODY ZONES protection requires the staged artifact") exercises = [] diff --git a/tools/export_exercise_profile_state.py b/tools/export_exercise_profile_state.py index ad53c7f..b61b64c 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): + if version not in (17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33): raise SystemExit(f"EXERCISE_PROFILE_STATE_EXPORT=FAIL schema={version}") rows = con.execute(""" SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields, diff --git a/tools/export_pc_catalog.py b/tools/export_pc_catalog.py index 1dc0ced..4fa0999 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, 33): + if version not in range(8, 34): raise SystemExit( "PC_CATALOG_EXPORT=FAIL " f"schema={version}" diff --git a/tools/export_pc_mobile.py b/tools/export_pc_mobile.py index a0c6afb..812c34c 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): + if args.version == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33): raise ValueError("mobile V4 exige le schéma desktop v19 ou v20") - supported_versions = range(11, 33) + supported_versions = range(11, 34) legacy_v2_schema = args.version == 2 and schema_version == 10 if schema_version not in supported_versions and not legacy_v2_schema: - raise ValueError("schema desktop v11-v32 requis (v10 accepté pour export V2 explicite)") + raise ValueError("schema desktop v11-v33 requis (v10 accepté pour export V2 explicite)") if not complete_causal_envelope and schema_version >= 20 and con.execute( "SELECT 1 FROM sync_causal_state WHERE deleted=1 LIMIT 1").fetchone(): raise ValueError("causal protection refuses a mobile snapshot that omits tombstones") diff --git a/tools/export_programs.py b/tools/export_programs.py index c8144c9..182a83b 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): + if db.execute("PRAGMA user_version").fetchone()[0] not in (26, 27, 28, 29, 30, 31, 32, 33): raise ValueError("desktop schema v26 through v32 required") programs = [] for row in db.execute( diff --git a/tools/export_session_preparations.py b/tools/export_session_preparations.py index 8e345cb..16465cb 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) + supported_versions = (24, 25, 26, 27, 28, 29, 30, 31, 32, 33) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: raise ValueError("desktop schema v24, v25, v26 through v32 required") deliveries = [] diff --git a/tools/export_training_feedback.py b/tools/export_training_feedback.py index ac0adc9..0e55b60 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, 33) + supported_versions = range(15, 34) if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - raise ValueError("schema desktop v15-v32 requis") + raise ValueError("schema desktop v15-v33 requis") if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0] >= 20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone(): raise ValueError("causal feedback protection requires the staged artifact") roots=db.execute("SELECT f.feedback_id,s.session_id,se.entry_id,e.exercise_id,f.observed_at FROM exercise_feedback f JOIN session_exercises se ON se.id=f.session_exercise_row_id JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id").fetchall() followups=db.execute("SELECT f.followup_id,s.session_id,f.observed_at FROM session_followups f JOIN sessions s ON s.id=f.session_row_id").fetchall() diff --git a/tools/heart_rate_exchange.py b/tools/heart_rate_exchange.py index 64d7c48..06a9c54 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): - fail("desktop schema v30-v32 required") + if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32, 33): + fail("desktop schema v30-v33 required") db.execute("BEGIN IMMEDIATE") apply(db, root) db.commit() diff --git a/tools/import_ai_session_draft.py b/tools/import_ai_session_draft.py index af7ee40..e8d75dc 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, 33) + supported_versions = range(18, 34) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - raise ImportFailure("schema desktop v18-v32 requis") + raise ImportFailure("schema desktop v18-v33 requis") draft, entries = validate(payload, connection) digest = canonical_digest(payload) imported_at = imported_at or dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") diff --git a/tools/import_equipment_associations.py b/tools/import_equipment_associations.py index 08534cf..c7a993a 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, 33) + supported_versions = range(8, 34) if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions: - fail("schema desktop v8 à v32 requis") + fail("schema desktop v8 à v33 requis") proof_path = args.mobile_export if proof_path is None: candidate = args.artifact.with_name("trainlog-mobile-export-v2.json") diff --git a/tools/import_equipment_definitions.py b/tools/import_equipment_definitions.py index 4369a17..ba40569 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, 33) + supported_versions = range(8, 34) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - fail("schema desktop v8 à v32 requis") + fail("schema desktop v8 à v33 requis") if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone(): fail("causal equipment protection requires the staged artifact") imported = skipped = 0 diff --git a/tools/import_exercise_aliases.py b/tools/import_exercise_aliases.py index ffa6f63..4d82976 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, 33) + supported_versions = range(12, 34) if con.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - fail("schema desktop v12-v32 requis") + fail("schema desktop v12-v33 requis") for source, canonical in aliases: target = con.execute("SELECT id,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?", (canonical,)).fetchone() if target is None: fail(f"cible canonique absente: {canonical}") diff --git a/tools/import_exercise_body_zones.py b/tools/import_exercise_body_zones.py index 9f43e6e..a0f2687 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, 33) + supported_versions = range(11, 34) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - raise ImportFailure("schema desktop v11-v32 requis") + raise ImportFailure("schema desktop v11-v33 requis") if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0]>=20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone(): raise ImportFailure("causal BODY ZONES protection requires the staged artifact") connection.execute("PRAGMA foreign_keys=ON");grouped={} # CONTRACT: resolve complete alias groups before the first write; direct diff --git a/tools/import_exercise_profile_state.py b/tools/import_exercise_profile_state.py index bfcf5e9..2c8f09f 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): - fail("schema desktop v17-v32 requis") + 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33): + fail("schema desktop v17-v33 requis") applied=equal=ancestor=pending=0;seen=set() for item in root["exercises"]: validate(item);eid=item["exercise_id"] diff --git a/tools/import_mobile_export.py b/tools/import_mobile_export.py index 967360b..f5d1e7f 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, 33): + if version not in range(5, 34): raise ImportFailure( - f"base desktop schema v5 à v32 attendue, version trouvée: {version}" + f"base desktop schema v5 à v33 attendue, version trouvée: {version}" ) @@ -2002,8 +2002,8 @@ def apply_payload(connection, payload, trace_exercises=False, complete_causal_en require_supported_schema(connection) schema_version = connection.execute("PRAGMA user_version;").fetchone()[0] - if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32): - raise ImportFailure("mobile V4 exige le schéma desktop v19 à v32") + if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33): + raise ImportFailure("mobile V4 exige le schéma desktop v19 à v33") if schema_version >= 20: protected = {(row[0], row[1]) for row in connection.execute( "SELECT target_kind,target_id FROM sync_causal_state WHERE deleted=1")} diff --git a/tools/import_training_feedback.py b/tools/import_training_feedback.py index 58bd410..6dc7415 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, 33) + supported_versions = range(15, 34) if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - fail("schema desktop v15-v32 requis") + fail("schema desktop v15-v33 requis") if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0]>=20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone():fail("causal feedback protection requires the staged artifact") seen=set() for index,item in enumerate(root["exercise_feedback"]): diff --git a/tools/package_sync_candidate.py b/tools/package_sync_candidate.py index a432cc0..bd1430c 100644 --- a/tools/package_sync_candidate.py +++ b/tools/package_sync_candidate.py @@ -19,6 +19,7 @@ RUNTIME_TOOLS = [ "sync_generation_exchange.py", "sleep_diary_exchange.py", "cardio_session_exchange.py", + "cardio_calibration_exchange.py", "heart_rate_exchange.py", "session_timeline_exchange.py", "trainlog_sqlite.py", @@ -117,8 +118,8 @@ def main() -> int: ["git", "rev-parse", "HEAD"], cwd=ROOT, text=True ).strip(), "product_version": "0.1.5", - "desktop_schema": 32, - "android_schema": 29, + "desktop_schema": 33, + "android_schema": 30, "protocols": [ "mobile-export-v3", "mobile-history-v4", @@ -134,6 +135,7 @@ def main() -> int: "program-executions-v1", "sleep-diary-v1", "cardio-sessions-v1", + "cardio-calibrations-v1", "heart-rate-v1", "session-timeline-v1", ], diff --git a/tools/program_execution_exchange.py b/tools/program_execution_exchange.py index 1ac2916..161a06c 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): + if db.execute("PRAGMA user_version").fetchone()[0] not in (27, 28, 29, 30, 31, 32, 33): fail("desktop schema v27 through v32 required") added = 0 advanced = 0 diff --git a/tools/session_timeline_exchange.py b/tools/session_timeline_exchange.py index 2eac26e..db54faf 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): - fail("desktop schema v31-v32 required") + if db.execute("PRAGMA user_version").fetchone()[0] not in (31, 32, 33): + fail("desktop schema v31-v33 required") db.execute("BEGIN IMMEDIATE") apply(db, root) db.commit() diff --git a/tools/sleep_diary_exchange.py b/tools/sleep_diary_exchange.py index 4df5668..b0ae44f 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): - fail("desktop schema v30-v32 required") + if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32, 33): + fail("desktop schema v30-v33 required") if args.command == "export": args.output.write_text(json.dumps(build(db), ensure_ascii=False, sort_keys=True, separators=(",", ":")), encoding="utf-8") diff --git a/tools/sync_generation_exchange.py b/tools/sync_generation_exchange.py index d880e68..4c6fa25 100644 --- a/tools/sync_generation_exchange.py +++ b/tools/sync_generation_exchange.py @@ -21,6 +21,7 @@ from pathlib import Path, PurePosixPath from trainlog_sqlite import connect_database import causal_delete_exchange import cardio_session_exchange +import cardio_calibration_exchange import execution_draft_exchange import heart_rate_exchange import session_timeline_exchange @@ -85,6 +86,7 @@ ARTIFACTS = ( SUPPORTED = {(row[1], row[2]) for row in ARTIFACTS} | { ("trainlog-program-executions", 1), ("trainlog-cardio-sessions", 1), + ("trainlog-cardio-calibrations", 1), ("trainlog-heart-rate", 1), ("trainlog-session-timeline", 1), } @@ -215,7 +217,7 @@ def validate_manifest_bytes(raw: bytes, expected_consumer: str | None = None): def require_schema(db: sqlite3.Connection) -> None: - supported_versions = (24, 25, 26, 27, 28, 29, 30, 31, 32) + supported_versions = (24, 25, 26, 27, 28, 29, 30, 31, 32, 33) if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: raise GenerationError("desktop schema v24, v25, v26 through v32 required") @@ -681,6 +683,10 @@ def consume_desktop(database: Path, directory: Path) -> dict: if "sleep-diary" in listed else None) heart_rate = (heart_rate_exchange.load(listed["heart-rate"]) if "heart-rate" in listed else None) + cardio_calibrations = ( + cardio_calibration_exchange.load(listed["cardio-calibrations"]) + if "cardio-calibrations" in listed else None + ) session_timeline = (session_timeline_exchange.load(listed["session-timeline"]) if "session-timeline" in listed else None) program_executions = ( @@ -734,6 +740,8 @@ def consume_desktop(database: Path, directory: Path) -> dict: sleep_diary_exchange.apply(db, sleep_diary) if heart_rate is not None: heart_rate_exchange.apply(db, heart_rate) + if cardio_calibrations is not None: + cardio_calibration_exchange.apply(db, cardio_calibrations) if session_timeline is not None: session_timeline_exchange.apply(db, session_timeline) if program_executions is not None: diff --git a/tui/include/trainlog/database.h b/tui/include/trainlog/database.h index e56251c..90b273e 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 32 +#define TRAINLOG_DATABASE_SCHEMA_VERSION 33 typedef struct TrainlogDatabase TrainlogDatabase; diff --git a/tui/meson.build b/tui/meson.build index 361f7f2..393d38e 100644 --- a/tui/meson.build +++ b/tui/meson.build @@ -847,6 +847,14 @@ test_schema_v32_migration = executable( ) test('schema_v32_migration', test_schema_v32_migration) +test_schema_v33_migration = executable( + 'test_schema_v33_migration', + 'tests/test_schema_v33_migration.c', + dependencies: trainlog_core_dep, + c_args: strict_c_args, +) +test('schema_v33_migration', test_schema_v33_migration) + test_schema_v7_migration = executable( 'test_schema_v7_migration', 'tests/test_schema_v7_migration.c', @@ -941,6 +949,11 @@ test( find_program('python3'), args: [meson.project_source_root() / 'tests/test_cardio_session_exchange.py'], ) +test( + 'cardio_calibration_exchange', + find_program('python3'), + args: [meson.project_source_root() / 'tests/test_cardio_calibration_exchange.py'], +) test( 'session_preparation_export', find_program('python3'), diff --git a/tui/src/database.c b/tui/src/database.c index 9051bff..e1bbe52 100644 --- a/tui/src/database.c +++ b/tui/src/database.c @@ -1401,6 +1401,31 @@ static const char *const MIGRATE_V31_TO_V32_SQL = "BEGIN IMMEDIATE;" "PRAGMA user_version=32;COMMIT;"; +/* WHY: guided cardio must retain the exact measured calibration reference + * independently from the raw HR samples. CONTRACT: v33 stores only completed + * imported calibration metadata/recovery points; the source curve remains in + * Heart Rate V1. INVARIANT: no session or HR sample is inferred or rewritten. */ +static const char *const MIGRATE_V32_TO_V33_SQL = + "BEGIN IMMEDIATE;" + "CREATE TABLE IF NOT EXISTS cardio_calibrations(" + "calibration_id TEXT PRIMARY KEY,protocol_version INTEGER NOT NULL CHECK(protocol_version=1)," + "session_id TEXT NOT NULL REFERENCES sessions(session_id) ON DELETE RESTRICT," + "entry_id TEXT NOT NULL,started_at TEXT NOT NULL,effort_end_at TEXT NOT NULL," + "ended_at TEXT NOT NULL,heart_rate_capture_id TEXT NOT NULL " + "REFERENCES heart_rate_captures(capture_id) ON DELETE RESTRICT," + "observed_peak_bpm INTEGER NOT NULL CHECK(observed_peak_bpm BETWEEN 0 AND 65535)," + "imported_at TEXT NOT NULL,CHECK(calibration_id GLOB 'cal_*')," + "CHECK(session_id GLOB 'se_*'),CHECK(entry_id GLOB 'sxe_*')," + "CHECK(heart_rate_capture_id GLOB 'hrc_*'));" + "CREATE TABLE IF NOT EXISTS cardio_calibration_recovery(" + "calibration_id TEXT NOT NULL REFERENCES cardio_calibrations(calibration_id) ON DELETE CASCADE," + "target_offset_seconds INTEGER NOT NULL CHECK(target_offset_seconds IN(60,120,180))," + "observed_at TEXT NOT NULL,bpm INTEGER NOT NULL CHECK(bpm BETWEEN 0 AND 65535)," + "PRIMARY KEY(calibration_id,target_offset_seconds));" + "CREATE INDEX IF NOT EXISTS cardio_calibration_session ON " + "cardio_calibrations(session_id,ended_at,calibration_id);" + "PRAGMA user_version=33;COMMIT;"; + static TrainlogStatus ensure_v32_session_kind(TrainlogDatabase *database) { sqlite3_stmt *statement = NULL; bool found_kind = false; @@ -2270,7 +2295,7 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database, version == 16 || version == 17 || version == 18 || version == 19 || version == 20 || version == 21 || version == 22 || version == 23 || version == 24 || version == 25 || version == 26 || version == 27 || version == 28 || version == 29 || version == 30 || - version == 31 || version == 32) { + version == 31 || version == 32 || version == 33) { status = TRAINLOG_STATUS_OK; } else { if (version == 1) { @@ -2430,6 +2455,9 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database, if (status == TRAINLOG_STATUS_OK && version < 32) { status = execute_sql(database, MIGRATE_V31_TO_V32_SQL); } + if (status == TRAINLOG_STATUS_OK && version < 33) { + status = execute_sql(database, MIGRATE_V32_TO_V33_SQL); + } if (status == TRAINLOG_STATUS_OK) { status = ensure_v18_ai_draft_publication_state(database); } @@ -2449,7 +2477,7 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database, if (status != TRAINLOG_STATUS_OK) { set_open_diagnostic(output_diagnostic, output_diagnostic_capacity, - version == 0 ? "create schema v32" : "migrate database to schema v32", + version == 0 ? "create schema v33" : "migrate database to schema v33", database->connection, SQLITE_ERROR); (void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL); diff --git a/tui/tests/test_schema_v29_migration.c b/tui/tests/test_schema_v29_migration.c index 43284b5..0dddd74 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") == 32); + CHECK(scalar(raw, "PRAGMA user_version") == 33); CHECK(scalar(raw, "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN " "('sleep_diary_entries','sleep_diary_revisions','sleep_diary_events'," @@ -97,7 +97,7 @@ int main(void) { trainlog_database_close(production); production = NULL; CHECK(sqlite3_open(path, &raw) == SQLITE_OK); - CHECK(scalar(raw, "PRAGMA user_version") == 32); + CHECK(scalar(raw, "PRAGMA user_version") == 33); CHECK(scalar(raw, "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name LIKE " "'sleep_medication%'") == 3); diff --git a/tui/tests/test_schema_v30_migration.c b/tui/tests/test_schema_v30_migration.c index c90d21c..a4525bf 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 == 32); + CHECK(version == 33); 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 b3dcf8c..5781a5c 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 == 32); + CHECK(version == 33); 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 7e77a4e..3fe5d72 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 == 32); + CHECK(version == 33); trainlog_database_close(production); production = NULL; diff --git a/tui/tests/test_schema_v33_migration.c b/tui/tests/test_schema_v33_migration.c new file mode 100644 index 0000000..da7e0ba --- /dev/null +++ b/tui/tests/test_schema_v33_migration.c @@ -0,0 +1,67 @@ +#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-v33-XXXXXX"; + TrainlogDatabase *production = NULL; + sqlite3 *raw = NULL; + int descriptor = mkstemp(path); + int version = 0; + int result = EXIT_FAILURE; + + CHECK(descriptor >= 0 && close(descriptor) == 0); + CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK); + trainlog_database_close(production); + production = NULL; + + CHECK(sqlite3_open(path, &raw) == SQLITE_OK); + CHECK(sqlite3_exec(raw, + "DROP TABLE cardio_calibration_recovery;" + "DROP TABLE cardio_calibrations;" + "PRAGMA user_version=32;", + NULL, NULL, NULL) == SQLITE_OK); + CHECK(sqlite3_close(raw) == SQLITE_OK); + raw = NULL; + + CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_schema_version(production, &version) == TRAINLOG_STATUS_OK); + CHECK(version == 33); + trainlog_database_close(production); + production = NULL; + + CHECK(sqlite3_open(path, &raw) == SQLITE_OK); + CHECK(scalar(raw, + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN(" + "'cardio_calibrations','cardio_calibration_recovery')") == 2); + CHECK(scalar(raw, + "SELECT COUNT(*) FROM sqlite_master WHERE type='index' " + "AND name='cardio_calibration_session'") == 1); + CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_foreign_key_check") == 0); + result = EXIT_SUCCESS; + +cleanup: + if (production != NULL) trainlog_database_close(production); + if (raw != NULL) (void)sqlite3_close(raw); + (void)unlink(path); + if (result == EXIT_SUCCESS) puts("PASS schema v32 to v33 cardio calibration migration"); + return result; +} diff --git a/tui/tests/test_schema_v7_migration.c b/tui/tests/test_schema_v7_migration.c index 8fbdc31..8637aa4 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 v32: SQLite rc=", - strlen("migrate database to schema v32: SQLite rc=")) == 0); + "migrate database to schema v33: SQLite rc=", + strlen("migrate database to schema v33: SQLite rc=")) == 0); CHECK(strstr(diagnostic, "extended_rc=") != NULL); CHECK(strstr(diagnostic, "custom_equipment") != NULL); CHECK(strstr(diagnostic, "already exists") != NULL); @@ -197,12 +197,12 @@ static bool test_newer_schema_has_application_diagnostic(void) { CHECK(fd >= 0); CHECK(close(fd) == 0); CHECK(sqlite3_open(path, &raw) == SQLITE_OK); - CHECK(sqlite3_exec(raw, "PRAGMA user_version=33;", NULL, NULL, NULL) == SQLITE_OK); + CHECK(sqlite3_exec(raw, "PRAGMA user_version=34;", NULL, NULL, NULL) == SQLITE_OK); CHECK(sqlite3_close(raw) == SQLITE_OK); CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) == TRAINLOG_STATUS_SCHEMA_UNSUPPORTED); CHECK(database == NULL); - CHECK(strstr(diagnostic, "schema version 33 is newer") != NULL); + CHECK(strstr(diagnostic, "schema version 34 is newer") != NULL); CHECK(strstr(diagnostic, "SQLite") == NULL); CHECK(unlink(path) == 0); return true; diff --git a/tui/tests/test_schema_v9_migration.c b/tui/tests/test_schema_v9_migration.c index c216126..48ace19 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 v32") != NULL); + CHECK(strstr(diagnostic, "migrate database to schema v33") != NULL); CHECK(strstr(diagnostic, "performed_sets_v9") != NULL); CHECK(sqlite3_open(path, &raw) == SQLITE_OK);