fix(cardio): keep live capture coherent across session type changes

This commit is contained in:
fy59 2026-09-23 18:14:45 +02:00
parent 41d0f1ff96
commit 5a7f80049a
5 changed files with 277 additions and 2 deletions

View file

@ -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.

View file

@ -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,

View file

@ -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()

View file

@ -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` |

View file

@ -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.