diff --git a/CHANGELOG.md b/CHANGELOG.md index 1834b2d..3c9615a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,16 @@ timeline, calibration provenance and exact raw HR evidence for instruction events carrying BPM before accepting them. +- Fixed live Cardio guidance after changing an already-started manual draft + from Training to Cardio. Draft persistence now atomically reconciles the + same active heart-rate capture from `session` to `cardio` without replacing + its identity or losing existing BPM/RR samples and sequence. A transition + away from Cardio remains possible before Cardio-only facts exist, but is + rejected once durable guidance or calibration facts would make it + ambiguous. Repository coverage reproduces the real ordering through an + active exercise and guidance event; a private vc46 device re-smoke remains + required. + - Added factual synchronized heart-rate timelines to the local Web. One bounded C17 read model and loopback GET endpoint now project raw BPM/RR measurements with canonical workout exercise intervals or Sleep Diary/medication markers. Cardio history additionally presents resolved phase targets, instruction changes and calibration peak/recovery facts. History and Sleep reuse one responsive SVG component; full-sample min/max/mean statistics remain exact while only very large chart projections retain bounded bucket extrema. No sleep stage, HRV, readiness or medical inference is added. Development opened after stable v0.1.4 release. 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 1ca2122..85679f8 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 @@ -10404,6 +10404,21 @@ class TrainlogRepository( db: SQLiteDatabase, draft: ActiveSessionDraft, ) { + val persistedSession = + db.rawQuery( + "SELECT session_id,session_kind FROM active_session_draft WHERE id=?", + arrayOf(ACTIVE_DRAFT_ID.toString()), + ).use { cursor -> + if (!cursor.moveToFirst()) null else cursor.getString(0) to cursor.getString(1) + } + if ( + persistedSession != null && + persistedSession.second == SessionType.CARDIO.wireValue && + draft.sessionType != SessionType.CARDIO && + hasCardioOnlyFacts(db, persistedSession.first) + ) { + error("A cardio session with guidance or calibration facts cannot change type.") + } val selectedRowId = draft.form.selectedExercise ?.let { @@ -10464,6 +10479,8 @@ class TrainlogRepository( ) } + reconcileActiveSessionHeartRateContext(db, draft.sessionType) + ensureSessionTimelineForPersistedDraft(db, draft) /* CONTRACT: draft persistence currently rebuilds occurrence rows. @@ -10630,6 +10647,48 @@ class TrainlogRepository( } } + private fun hasCardioOnlyFacts( + db: SQLiteDatabase, + sessionId: String, + ): Boolean = + db.rawQuery( + "SELECT 1 FROM cardio_guidance_runs WHERE session_id=? " + + "UNION ALL SELECT 1 FROM cardio_calibrations WHERE session_id=? LIMIT 1", + arrayOf(sessionId, sessionId), + ).use { it.moveToFirst() } + + private fun reconcileActiveSessionHeartRateContext( + db: SQLiteDatabase, + sessionType: SessionType, + ) { + val sessionId = + db.rawQuery( + "SELECT session_id FROM active_session_draft WHERE id=?", + arrayOf(ACTIVE_DRAFT_ID.toString()), + ).use { cursor -> + check(cursor.moveToFirst()) + cursor.getString(0) + } + val expectedContext = + if (sessionType == SessionType.CARDIO) { + HeartRateContextKind.CARDIO + } else { + HeartRateContextKind.SESSION + } + + /* WHY: the sensor service can open the capture while a new manual draft still has its + * default Training type, before the user selects Cardio. + * CONTRACT: every successful mutable draft-type save atomically reconciles the same + * session's active capture; cardio-only durable facts prevent a later transition away. + * INVARIANT: capture identity, samples, RR facts, sequence and the single active slot are + * preserved; only the compatible session/cardio context discriminator may change. */ + db.execSQL( + "UPDATE heart_rate_captures SET context_kind=? WHERE active_slot=1 " + + "AND context_id=? AND context_kind IN('session','cardio') AND context_kind<>?", + arrayOf(expectedContext.wireValue, sessionId, expectedContext.wireValue), + ) + } + private fun insertCompletedSession( db: SQLiteDatabase, draft: SessionDraft, diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/CardioGuidanceRepositoryTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/CardioGuidanceRepositoryTest.kt index 2e9a567..1cc97e8 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/CardioGuidanceRepositoryTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/CardioGuidanceRepositoryTest.kt @@ -1,6 +1,7 @@ package com.labfytools.trainlog.data import android.content.Context +import android.database.sqlite.SQLiteDatabase import androidx.test.core.app.ApplicationProvider import com.labfytools.trainlog.model.ActiveSessionDraft import com.labfytools.trainlog.model.CardioGuidanceInstruction @@ -9,6 +10,7 @@ import com.labfytools.trainlog.model.CardioGuidedPhase import com.labfytools.trainlog.model.CardioPhaseExitCondition import com.labfytools.trainlog.model.CardioPhaseKind import com.labfytools.trainlog.model.CardioTargetSnapshot +import com.labfytools.trainlog.model.HeartRateContextKind import com.labfytools.trainlog.model.NewExerciseProfile import com.labfytools.trainlog.model.RecordingMode import com.labfytools.trainlog.model.SessionExerciseDraft @@ -81,6 +83,201 @@ class CardioGuidanceRepositoryTest { return ActiveCardio(repository, name, entry.entryId, exerciseStartedAt) } + @Test + fun trainingCaptureTransitionsToCardioWithoutStarvingGuidance() { + val name = "guidance-transition-" + UUID.randomUUID() + ".db" + val repository = TrainlogRepository(context, name) + try { + val exercise = + (repository.createExercise( + NewExerciseProfile( + "Vélo guidé après transition", + RecordingMode.CONTINUOUS, + TrackingMode.DURATION, + 0, + ), + ) as CreateExerciseResult.Created).exercise + assertEquals(ActiveDraftMutationResult.Saved, repository.startActiveSessionDraft()) + val training = + (repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft + val base = OffsetDateTime.parse(checkNotNull(training.startedAt)) + val firstMeasurement = + ParsedHeartRateMeasurement( + bpm = 101, + sensorContactDetected = true, + energyExpended = 12, + rrIntervals1024 = listOf(611), + ) + assertTrue( + repository.recordLiveHeartRateForActiveSession( + "Synthetic HR", + base.plusSeconds(1).toString(), + firstMeasurement, + ) is TrainlogRepository.HeartRateMutationResult.Applied, + ) + val originalCapture = checkNotNull(repository.activeHeartRateCapture()) + assertEquals(HeartRateContextKind.SESSION, originalCapture.contextKind) + + assertEquals( + ActiveDraftMutationResult.Saved, + repository.saveActiveSessionDraft(training.copy(sessionType = SessionType.CARDIO)), + ) + val transitionedCapture = checkNotNull(repository.activeHeartRateCapture()) + assertEquals(originalCapture.captureId, transitionedCapture.captureId) + assertEquals(HeartRateContextKind.CARDIO, transitionedCapture.contextKind) + + val entry = + SessionExerciseDraft( + exercise = exercise, + plan = SessionExercisePlan(sets = 0, durationSeconds = 60), + ) + val cardio = + (repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft + assertEquals( + ActiveDraftMutationResult.Saved, + repository.saveActiveSessionDraft(cardio.copy(exercises = listOf(entry))), + ) + assertTrue( + repository.startActiveSessionExercise(entry.entryId, base.plusSeconds(2).toString()) is + TrainlogRepository.SessionExerciseTimingResult.Started, + ) + val target = CardioTargetSnapshot(100, 110) + val phase = + CardioGuidedPhase( + phaseId = "cgp_" + UUID.randomUUID(), + kind = CardioPhaseKind.WARMUP, + target = target, + exitCondition = CardioPhaseExitCondition.FixedDuration(60), + ) + assertTrue( + repository.startCardioGuidancePhase( + entry.entryId, + phase, + base.plusSeconds(3).toString(), + ) is TrainlogRepository.CardioGuidanceMutationResult.Applied, + ) + val secondAt = base.plusSeconds(4).toString() + assertTrue( + repository.recordLiveHeartRateForActiveSession( + "Synthetic HR", + secondAt, + firstMeasurement.copy(bpm = 99), + ) is TrainlogRepository.HeartRateMutationResult.Applied, + ) + assertTrue( + repository.recordCardioGuidanceOutput( + phase.phaseId, + secondAt, + CardioGuidanceOutput(CardioGuidanceInstruction.ACCELERATE, 99, target), + ) is TrainlogRepository.CardioGuidanceMutationResult.Applied, + ) + assertEquals( + listOf(CardioGuidanceInstruction.ACCELERATE), + repository.listCardioGuidanceEvents(phase.phaseId).map { it.instruction }, + ) + + val lateReverse = + repository.saveActiveSessionDraft( + (repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded) + .draft + .copy(sessionType = SessionType.TRAINING), + ) + assertTrue(lateReverse is ActiveDraftMutationResult.Error) + assertEquals( + SessionType.CARDIO, + (repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded) + .draft + .sessionType, + ) + assertEquals( + HeartRateContextKind.CARDIO, + checkNotNull(repository.activeHeartRateCapture()).contextKind, + ) + + SQLiteDatabase.openDatabase( + context.getDatabasePath(name).path, + null, + SQLiteDatabase.OPEN_READONLY, + ).use { db -> + db.rawQuery( + "SELECT sequence,bpm,exercise_entry_id FROM heart_rate_samples " + + "WHERE capture_id=? ORDER BY sequence", + arrayOf(originalCapture.captureId), + ).use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals(0L, cursor.getLong(0)) + assertEquals(101, cursor.getInt(1)) + assertTrue(cursor.isNull(2)) + assertTrue(cursor.moveToNext()) + assertEquals(1L, cursor.getLong(0)) + assertEquals(99, cursor.getInt(1)) + assertEquals(entry.entryId, cursor.getString(2)) + assertTrue(!cursor.moveToNext()) + } + } + + assertEquals(ActiveDraftMutationResult.Saved, repository.discardActiveSessionDraft()) + assertNull(repository.activeHeartRateCapture()) + assertNull(repository.activeCardioGuidancePhase()) + assertTrue(repository.listActiveSessionExerciseTimings().isEmpty()) + } finally { + repository.close() + context.deleteDatabase(name) + } + } + + @Test + fun cardioCaptureCanReturnToTrainingBeforeCardioOnlyFactsExist() { + val name = "guidance-early-reverse-" + UUID.randomUUID() + ".db" + val repository = TrainlogRepository(context, name) + try { + assertEquals(ActiveDraftMutationResult.Saved, repository.startActiveSessionDraft()) + val training = + (repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft + val observedAt = + OffsetDateTime.parse(checkNotNull(training.startedAt)).plusSeconds(1).toString() + assertTrue( + repository.recordLiveHeartRateForActiveSession( + "Synthetic HR", + observedAt, + ParsedHeartRateMeasurement(103, null, null, listOf(600)), + ) is TrainlogRepository.HeartRateMutationResult.Applied, + ) + val captureId = checkNotNull(repository.activeHeartRateCapture()).captureId + assertEquals( + ActiveDraftMutationResult.Saved, + repository.saveActiveSessionDraft(training.copy(sessionType = SessionType.CARDIO)), + ) + val cardio = + (repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft + assertEquals( + ActiveDraftMutationResult.Saved, + repository.saveActiveSessionDraft(cardio.copy(sessionType = SessionType.TRAINING)), + ) + val reversed = checkNotNull(repository.activeHeartRateCapture()) + assertEquals(captureId, reversed.captureId) + assertEquals(HeartRateContextKind.SESSION, reversed.contextKind) + + SQLiteDatabase.openDatabase( + context.getDatabasePath(name).path, + null, + SQLiteDatabase.OPEN_READONLY, + ).use { db -> + db.rawQuery( + "SELECT COUNT(*),MIN(bpm) FROM heart_rate_samples WHERE capture_id=?", + arrayOf(captureId), + ).use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals(1, cursor.getInt(0)) + assertEquals(103, cursor.getInt(1)) + } + } + } finally { + repository.close() + context.deleteDatabase(name) + } + } + @Test fun phaseRequiresActiveCardioExerciseAndRecordsOnlyInstructionChanges() { val active = activeCardio() diff --git a/docs/current_state.md b/docs/current_state.md index 6ef0955..dad0191 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -156,8 +156,8 @@ parallel implementation of its rules. | 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 | | Cardio BPM guidance | `TRAINLOG_CARDIO_BPM_GUIDANCE_V1=PASS`: absolute/calibration-derived immutable BPM targets, 2 BPM hysteresis, 3 s confirmation, stale-signal suspension, durable phase/condition history, Android live guidance UI and strict Android→desktop `trainlog-cardio-guidance` V1 sync | | Cardio Web visualization | `TRAINLOG_CARDIO_WEB_VISUALIZATION_V1=PASS`: one bounded factual heart-rate timeline read model/HTTP endpoint powers Web History and Sleep with raw BPM/RR, real session/exercise/Sleep markers, guided targets/instructions and calibration facts; no sleep-stage or medical inference | -| 0.1.5 validation | `TRAINLOG_0_1_5_VALIDATION_V1=SOFTWARE_PASS / DEVICE_VISUAL_PENDING`: complete native 111/111, Android 300 tests (295 pass + 5 historical skips), Web 183/183 + typecheck/build, vc43 in-place private update with schema v31/integrity/data preservation and live CYCPLUS 94–95 BPM foreground capture validated while locked | -| Current operational cursor | `TRAINLOG_0_1_5_DEVICE_VISUAL_REVIEW_V1`: unlock the private phone and visually smoke-test the Android Sommeil, Calibration cardio and active guided-cardio presentation before any stable release decision | +| 0.1.5 validation | `TRAINLOG_0_1_5_VALIDATION_V1=SOFTWARE_PASS / DEVICE_VISUAL_PENDING`: native 111/111; Android 303 tests (298 pass + 5 historical skips) and debug APK build; Web 183/183 + typecheck/build baseline. The vc45 device review proved that a Training → Cardio draft transition stranded the already-active HR capture; repository persistence now reconciles that capture atomically with sample continuity, and a vc46 re-smoke remains required. | +| Current operational cursor | `TRAINLOG_0_1_5_DEVICE_VISUAL_REVIEW_V1`: deploy the separately built vc46 candidate to the private phone and re-smoke Sommeil, Calibration cardio and the corrected active guided-cardio Training → Cardio transition before any stable release decision | | 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/roadmap.md b/docs/roadmap.md index 96a5b48..bc6506b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -72,6 +72,15 @@ Bluetooth synchronization transport contract. CURRENT_OPERATIONAL_CURSOR=TRAINLOG_0_1_5_DEVICE_VISUAL_REVIEW_V1 ``` +The vc45 real-device smoke found that selecting Cardio after live capture had +already started on the default Training draft left the durable capture tagged +as `session`, starving Cardio guidance despite fresh global BPM. Repository +persistence now reconciles the same capture atomically while retaining its +identity, sample order and raw BPM/RR facts; automated coverage reproduces the +transition through an active exercise and guidance event. The cursor remains +pending until the corrected flow is re-smoked on the separately deployed vc46 +candidate. + The focused `TRAINLOG_SYNC_CAUSAL_DELETE_V1_CLOSEOUT` is complete before this cursor: complete predecessor state, imported built-in authorization, cross-platform draft revision identity and resource-bound admission are closed.