feat(android): add one-tap sleep capture

This commit is contained in:
fy59 2026-09-23 11:47:32 +02:00
parent dd39c85f94
commit 070a29298f
10 changed files with 739 additions and 4 deletions

View file

@ -31,6 +31,12 @@
unclosed occurrence. Live heart-rate samples snapshot the active `entry_id`,
rest intervals remain unowned, and the optional `trainlog-session-timeline`
V1 companion imports idempotently into desktop schema v31.
- Added Android one-tap Sleep capture on top of the existing Sleep Diary V1:
Couché, active medication buttons, point Réveil events and Levé each record
the current timestamp in one action. A guarded short-lived Annuler creates a
causal compensating revision, while Corriger la nuit retains the complete
editor. Heart-rate acquisition links directly to the stable sleep `entry_id`
and closes at Levé; no return-to-sleep or sleep-stage inference is added.
Development opened after stable v0.1.4 release.

View file

@ -277,9 +277,15 @@ class HeartRateSensorService : Service() {
if (parsed !is HeartRateMeasurementParseResult.Parsed) return
lastMeasurementElapsed = SystemClock.elapsedRealtime()
HeartRateLiveState.measurement(parsed.measurement)
repository().recordLiveHeartRateForActiveSession(
val observedAt = OffsetDateTime.now().toString()
val repository = repository()
repository.recordLiveHeartRateForActiveSession(
sensorName = selected?.name,
observedAt = OffsetDateTime.now().toString(),
observedAt = observedAt,
measurement = parsed.measurement,
) ?: repository.recordLiveHeartRateForActiveSleep(
sensorName = selected?.name,
observedAt = observedAt,
measurement = parsed.measurement,
)
if (lastNotificationBpm != parsed.measurement.bpm) {

View file

@ -492,6 +492,21 @@ class TrainlogRepository(
data class Rejected(val reason: String) : SleepDiaryImportResult
}
data class SleepQuickActionReceipt(
val entryId: String,
val previousRevisionId: String?,
val appliedRevisionId: String,
)
sealed interface SleepQuickActionResult {
data class Applied(val receipt: SleepQuickActionReceipt) : SleepQuickActionResult
data object NoActiveNight : SleepQuickActionResult
data object AlreadyActive : SleepQuickActionResult
data object InvalidMedication : SleepQuickActionResult
data object Conflict : SleepQuickActionResult
data object Error : SleepQuickActionResult
}
sealed interface StartHeartRateCaptureResult {
data class Started(val captureId: String) : StartHeartRateCaptureResult
data object Invalid : StartHeartRateCaptureResult
@ -668,6 +683,257 @@ class TrainlogRepository(
} finally { db.endTransaction() }
}
private fun activeSleepDiaryEntry(): SleepDiaryEntry? =
listSleepDiary().firstOrNull { entry ->
entry.events.any { it.type == SleepEventType.BED_TIME } &&
entry.events.none { it.type == SleepEventType.FINAL_GET_UP }
}
private fun quickSleepDates(at: String): Pair<String, String>? =
runCatching {
val moment = OffsetDateTime.parse(at)
val start = if (moment.hour >= 18) moment.toLocalDate() else moment.toLocalDate().minusDays(1)
start.toString() to start.plusDays(1).toString()
}.getOrNull()
fun quickSleepBedTime(at: String = OffsetDateTime.now().toString()): SleepQuickActionResult {
if (activeSleepDiaryEntry() != null) return SleepQuickActionResult.AlreadyActive
val dates = quickSleepDates(at) ?: return SleepQuickActionResult.Error
val result = saveSleepDiary(
SleepDiaryDraft(
nightStartDate = dates.first,
nightEndDate = dates.second,
createdAt = at,
updatedAt = at,
sleepQuality = null,
wakeQuality = null,
dayForm = null,
treatmentAndNotes = "",
events = listOf(SleepDiaryEvent(sleepId("sle"), SleepEventType.BED_TIME, at, null)),
),
)
return when (result) {
is SaveSleepDiaryResult.Saved ->
SleepQuickActionResult.Applied(
SleepQuickActionReceipt(result.entryId, null, result.revisionId),
)
SaveSleepDiaryResult.Conflict -> SleepQuickActionResult.Conflict
else -> SleepQuickActionResult.Error
}
}
private fun mutateActiveSleep(
at: String,
transform: (SleepDiaryEntry) -> SleepDiaryDraft,
): SleepQuickActionResult {
if (runCatching { OffsetDateTime.parse(at) }.isFailure) return SleepQuickActionResult.Error
val current = activeSleepDiaryEntry() ?: return SleepQuickActionResult.NoActiveNight
return when (val result = saveSleepDiary(transform(current))) {
is SaveSleepDiaryResult.Saved ->
SleepQuickActionResult.Applied(
SleepQuickActionReceipt(current.entryId, current.revisionId, result.revisionId),
)
SaveSleepDiaryResult.Conflict -> SleepQuickActionResult.Conflict
else -> SleepQuickActionResult.Error
}
}
private fun SleepDiaryEntry.quickDraft(
at: String,
events: List<SleepDiaryEvent> = this.events,
intakes: List<MedicationIntake> = this.intakes,
) = SleepDiaryDraft(
entryId = entryId,
expectedRevision = revisionId,
nightStartDate = nightStartDate,
nightEndDate = nightEndDate,
createdAt = createdAt,
updatedAt = at,
sleepQuality = sleepQuality,
wakeQuality = wakeQuality,
dayForm = dayForm,
treatmentAndNotes = treatmentAndNotes,
events = events,
intakes = intakes,
)
fun quickSleepWake(at: String = OffsetDateTime.now().toString()): SleepQuickActionResult =
mutateActiveSleep(at) { current ->
current.quickDraft(
at,
events = current.events + SleepDiaryEvent(
sleepId("sle"),
SleepEventType.NIGHT_GET_UP,
at,
null,
),
)
}
fun quickSleepMedication(
medicationId: String,
at: String = OffsetDateTime.now().toString(),
): SleepQuickActionResult {
val medication =
listSleepMedications(includeInactive = false)
.firstOrNull { it.medicationId == medicationId }
?: return SleepQuickActionResult.InvalidMedication
return mutateActiveSleep(at) { current ->
current.quickDraft(
at,
intakes = current.intakes + MedicationIntake(
intakeId = sleepId("mdi"),
medicationId = medication.medicationId,
medicationName = medication.name,
takenAt = at,
doseValue = medication.defaultDoseValue,
doseUnit = medication.defaultDoseUnit,
note = "",
createdAt = at,
),
)
}
}
fun quickSleepFinalGetUp(at: String = OffsetDateTime.now().toString()): SleepQuickActionResult {
val activeBefore = activeSleepDiaryEntry() ?: return SleepQuickActionResult.NoActiveNight
val result =
mutateActiveSleep(at) { current ->
current.quickDraft(
at,
events = current.events + SleepDiaryEvent(
sleepId("sle"),
SleepEventType.FINAL_GET_UP,
at,
null,
),
)
}
if (result is SleepQuickActionResult.Applied) {
activeHeartRateCapture()
?.takeIf {
it.contextKind == HeartRateContextKind.SLEEP &&
it.contextId == activeBefore.entryId
}
?.let { stopHeartRateCapture(it.captureId, at) }
}
return result
}
private fun loadSleepDiaryRevision(entryId: String, revisionId: String): SleepDiaryEntry? {
val db = database.readableDatabase
val owner = db.rawQuery(
"SELECT night_start_date,night_end_date,created_at,updated_at FROM sleep_diary_entries " +
"WHERE entry_id=? AND deleted=0",
arrayOf(entryId),
).use { c ->
if (!c.moveToFirst()) null else arrayOf(c.getString(0), c.getString(1), c.getString(2), c.getString(3))
} ?: return null
val values = db.rawQuery(
"SELECT sleep_quality,wake_quality,day_form,treatment_and_notes FROM sleep_diary_revisions " +
"WHERE entry_id=? AND revision_id=?",
arrayOf(entryId, revisionId),
).use { c ->
if (!c.moveToFirst()) null else arrayOf(c.getString(0), c.getString(1), c.getString(2), c.getString(3))
} ?: return null
val events = db.rawQuery(
"SELECT event_id,event_type,start_at,end_at FROM sleep_diary_events WHERE revision_id=? " +
"ORDER BY start_at,event_id",
arrayOf(revisionId),
).use { c -> buildList {
while (c.moveToNext()) add(SleepDiaryEvent(c.getString(0), SleepEventType.entries.single { it.wireValue == c.getString(1) }, c.getString(2), if (c.isNull(3)) null else c.getString(3)))
} }
val intakes = db.rawQuery(
"SELECT intake_id,medication_id,medication_name,taken_at,dose_value,dose_unit,note,created_at " +
"FROM sleep_medication_intakes WHERE revision_id=? ORDER BY taken_at,intake_id",
arrayOf(revisionId),
).use { c -> buildList {
while (c.moveToNext()) add(MedicationIntake(c.getString(0), c.getString(1), c.getString(2), c.getString(3), if (c.isNull(4)) null else c.getDouble(4), if (c.isNull(5)) null else c.getString(5), c.getString(6), c.getString(7)))
} }
fun quality(v: String?) = v?.let { text -> SleepQuality.entries.single { it.wireValue == text } }
return SleepDiaryEntry(entryId, owner[0], owner[1], owner[2], owner[3], revisionId,
quality(values[0]), quality(values[1]), quality(values[2]), values[3].orEmpty(),
events, intakes, SleepPublicationStatus.DRAFT)
}
fun undoSleepQuickAction(
receipt: SleepQuickActionReceipt,
at: String = OffsetDateTime.now().toString(),
): SleepQuickActionResult {
val current = listSleepDiary().firstOrNull { it.entryId == receipt.entryId }
?: return SleepQuickActionResult.Conflict
if (current.revisionId != receipt.appliedRevisionId) return SleepQuickActionResult.Conflict
if (receipt.previousRevisionId == null) {
return when (deleteSleepDiary(current.entryId, current.revisionId, at)) {
is SaveSleepDiaryResult.Saved ->
SleepQuickActionResult.Applied(
SleepQuickActionReceipt(current.entryId, current.revisionId, current.revisionId),
)
else -> SleepQuickActionResult.Conflict
}
}
val previous = loadSleepDiaryRevision(current.entryId, receipt.previousRevisionId)
?: return SleepQuickActionResult.Conflict
val compensating =
SleepDiaryDraft(
entryId = current.entryId,
expectedRevision = current.revisionId,
nightStartDate = previous.nightStartDate,
nightEndDate = previous.nightEndDate,
createdAt = previous.createdAt,
updatedAt = at,
sleepQuality = previous.sleepQuality,
wakeQuality = previous.wakeQuality,
dayForm = previous.dayForm,
treatmentAndNotes = previous.treatmentAndNotes,
events = previous.events,
intakes = previous.intakes,
)
return when (val result = saveSleepDiary(compensating)) {
is SaveSleepDiaryResult.Saved ->
SleepQuickActionResult.Applied(
SleepQuickActionReceipt(current.entryId, current.revisionId, result.revisionId),
)
else -> SleepQuickActionResult.Conflict
}
}
internal fun recordLiveHeartRateForActiveSleep(
sensorName: String?,
observedAt: String,
measurement: ParsedHeartRateMeasurement,
): HeartRateMutationResult? {
if (activeSessionTimelineContext() != null) return null
val db = database.readableDatabase
val sleep = db.rawQuery(
"SELECT e.entry_id,bed.start_at FROM sleep_diary_entries e " +
"JOIN sleep_diary_events bed ON bed.revision_id=e.current_revision_id " +
"AND bed.event_type='bed_time' WHERE e.deleted=0 AND NOT EXISTS(" +
"SELECT 1 FROM sleep_diary_events wake WHERE wake.revision_id=e.current_revision_id " +
"AND wake.event_type='final_get_up') ORDER BY bed.start_at DESC LIMIT 1",
null,
).use { c -> if (!c.moveToFirst()) null else c.getString(0) to c.getString(1) } ?: return null
var capture = activeHeartRateCapture()
if (capture == null) {
when (startHeartRateCapture(HeartRateContextKind.SLEEP, sleep.first, sleep.second, sensorName)) {
is StartHeartRateCaptureResult.Started -> capture = activeHeartRateCapture()
StartHeartRateCaptureResult.Conflict -> capture = activeHeartRateCapture()
else -> return HeartRateMutationResult.DatabaseError
}
}
val current = capture ?: return HeartRateMutationResult.NotFound
if (current.contextKind != HeartRateContextKind.SLEEP || current.contextId != sleep.first) return null
return appendHeartRateSample(
current.captureId,
observedAt,
measurement.bpm,
measurement.sensorContactDetected,
measurement.energyExpended,
measurement.rrIntervals1024.mapIndexed { index, value -> HeartRateRrInterval(index, value) },
)
}
fun listSleepDiary(limit: Int = 90): List<SleepDiaryEntry> {
require(limit in 1..3660)
val db = database.readableDatabase

View file

@ -17,6 +17,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import kotlinx.coroutines.delay
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
@ -37,8 +38,240 @@ import java.time.LocalDate
import java.time.OffsetDateTime
import java.util.UUID
@Composable
fun SleepDiaryScreen(repository: TrainlogRepository) {
var correctionMode by remember { mutableStateOf(false) }
if (correctionMode) {
Column(Modifier.fillMaxSize()) {
Button(
onClick = { correctionMode = false },
modifier = Modifier.fillMaxWidth().padding(12.dp),
) {
Text(stringResource(R.string.sleep_return_night_mode))
}
SleepDiaryEditorScreen(repository)
}
} else {
SleepNightCaptureScreen(repository) { correctionMode = true }
}
}
@Composable
private fun SleepNightCaptureScreen(
repository: TrainlogRepository,
onCorrection: () -> Unit,
) {
var revision by remember { mutableStateOf(0) }
var undoReceipt by remember {
mutableStateOf<TrainlogRepository.SleepQuickActionReceipt?>(null)
}
var message by remember { mutableStateOf<String?>(null) }
val strings = localizedContext()
val entries = remember(revision) { repository.listSleepDiary() }
val active =
entries.firstOrNull { entry ->
entry.events.any { it.type == SleepEventType.BED_TIME } &&
entry.events.none { it.type == SleepEventType.FINAL_GET_UP }
}
val medications =
remember(revision) {
repository.listSleepMedications(includeInactive = false)
}
LaunchedEffect(undoReceipt?.appliedRevisionId) {
if (undoReceipt != null) {
delay(10_000)
undoReceipt = null
}
}
fun apply(
successMessage: String,
result: TrainlogRepository.SleepQuickActionResult,
) {
when (result) {
is TrainlogRepository.SleepQuickActionResult.Applied -> {
undoReceipt = result.receipt
message = successMessage
revision++
}
TrainlogRepository.SleepQuickActionResult.NoActiveNight ->
message = strings.getString(R.string.sleep_quick_no_active)
TrainlogRepository.SleepQuickActionResult.AlreadyActive ->
message = strings.getString(R.string.sleep_quick_already_active)
TrainlogRepository.SleepQuickActionResult.InvalidMedication ->
message = strings.getString(R.string.sleep_quick_medication_unavailable)
TrainlogRepository.SleepQuickActionResult.Conflict ->
message = strings.getString(R.string.sleep_quick_conflict)
TrainlogRepository.SleepQuickActionResult.Error ->
message = strings.getString(R.string.sleep_quick_error)
}
}
LazyColumn(
modifier = Modifier.fillMaxSize().padding(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
Text(stringResource(R.string.sleep_night_capture_title))
}
item {
active?.let { night ->
Text(
stringResource(
R.string.sleep_night_in_progress,
night.nightStartDate,
night.nightEndDate,
),
)
} ?: Text(stringResource(R.string.sleep_no_night_active))
}
item {
Button(
onClick = {
apply(
strings.getString(R.string.sleep_bed_recorded),
repository.quickSleepBedTime(),
)
},
enabled = active == null,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.sleep_quick_bed))
}
}
if (medications.isNotEmpty()) {
item { Text(stringResource(R.string.sleep_medications)) }
items(medications, key = { it.medicationId }) { medication ->
Button(
onClick = {
apply(
strings.getString(R.string.sleep_medication_recorded, medication.name),
repository.quickSleepMedication(medication.medicationId),
)
},
enabled = active != null,
modifier = Modifier.fillMaxWidth(),
) {
val dose =
medication.defaultDoseValue?.let { doseValue ->
" · " + doseValue.toString().trimEnd('0').trimEnd('.') +
" " + medication.defaultDoseUnit.orEmpty()
}.orEmpty()
Text("💊 " + medication.name + dose)
}
}
}
item {
Button(
onClick = {
apply(
strings.getString(R.string.sleep_wake_recorded),
repository.quickSleepWake(),
)
},
enabled = active != null,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.sleep_quick_wake))
}
}
item {
Button(
onClick = {
apply(
strings.getString(R.string.sleep_get_up_recorded),
repository.quickSleepFinalGetUp(),
)
},
enabled = active != null,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.sleep_quick_get_up))
}
}
message?.let { text ->
item {
Card {
Column(
Modifier.fillMaxWidth().padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(text)
undoReceipt?.let { receipt ->
Button(
onClick = {
when (repository.undoSleepQuickAction(receipt)) {
is TrainlogRepository.SleepQuickActionResult.Applied -> {
message = strings.getString(R.string.sleep_action_undone)
undoReceipt = null
revision++
}
else -> {
message = strings.getString(R.string.sleep_undo_unavailable)
undoReceipt = null
revision++
}
}
},
) {
Text(stringResource(R.string.sleep_undo))
}
}
}
}
}
}
active?.let { night ->
item {
Card {
Column(
Modifier.fillMaxWidth().padding(12.dp),
verticalArrangement = Arrangement.spacedBy(5.dp),
) {
Text(stringResource(R.string.sleep_night_timeline))
night.events.sortedBy { it.startAt }.forEach { event ->
Text(eventLabelPlain(event.type, strings) + " · " + shortTime(event.startAt))
}
night.intakes.sortedBy { it.takenAt }.forEach { intake ->
Text("💊 " + intake.medicationName + " · " + shortTime(intake.takenAt))
}
}
}
}
}
item {
Button(
onClick = onCorrection,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.sleep_correct_night))
}
}
}
}
private fun shortTime(value: String): String =
runCatching { OffsetDateTime.parse(value).toLocalTime().withSecond(0).withNano(0).toString() }
.getOrDefault(value)
private fun eventLabelPlain(type: SleepEventType, context: android.content.Context): String =
context.getString(
when (type) {
SleepEventType.BED_TIME -> R.string.sleep_bed_time
SleepEventType.FINAL_GET_UP -> R.string.sleep_final_get_up
SleepEventType.NIGHT_GET_UP -> R.string.sleep_night_get_up
SleepEventType.SLEEP -> R.string.sleep_sleep
SleepEventType.NAP -> R.string.sleep_nap
SleepEventType.LONG_AWAKE -> R.string.sleep_long_awake
SleepEventType.HALF_SLEEP -> R.string.sleep_half_sleep
SleepEventType.DAYTIME_SLEEPINESS -> R.string.sleep_sleepiness
},
)
@Composable
private fun SleepDiaryEditorScreen(repository: TrainlogRepository) {
var revision by remember { mutableStateOf(0) }
var selected by remember(revision) { mutableStateOf<SleepDiaryEntry?>(repository.listSleepDiary().firstOrNull()) }
val today = LocalDate.now()

View file

@ -536,4 +536,27 @@
<string name="exercise_timeline_finalize_title">An exercise is still active</string>
<string name="exercise_timeline_finalize_detail">%1$s is still active. Finish it now and then save the session?</string>
<string name="exercise_timeline_finalize_confirm">Finish and save</string>
<string name="sleep_night_capture_title">Night capture</string>
<string name="sleep_no_night_active">No night in progress.</string>
<string name="sleep_night_in_progress">Night in progress · %1$s → %2$s</string>
<string name="sleep_quick_bed">🛏 BED</string>
<string name="sleep_quick_wake">👁 WAKE</string>
<string name="sleep_quick_get_up">☀ GET UP</string>
<string name="sleep_bed_recorded">Bed time recorded.</string>
<string name="sleep_wake_recorded">Wake recorded.</string>
<string name="sleep_get_up_recorded">Get-up recorded.</string>
<string name="sleep_medication_recorded">%1$s recorded.</string>
<string name="sleep_undo">UNDO</string>
<string name="sleep_action_undone">Last action undone.</string>
<string name="sleep_undo_unavailable">Undo unavailable: the night has already changed.</string>
<string name="sleep_correct_night">Correct the night</string>
<string name="sleep_return_night_mode">Back to night capture</string>
<string name="sleep_night_timeline">Night timeline</string>
<string name="sleep_quick_no_active">No night in progress.</string>
<string name="sleep_quick_already_active">A night is already in progress.</string>
<string name="sleep_quick_medication_unavailable">Medication unavailable.</string>
<string name="sleep_quick_conflict">The night changed; reload before trying again.</string>
<string name="sleep_quick_error">Unable to record the action.</string>
</resources>

View file

@ -546,4 +546,27 @@
<string name="exercise_timeline_finalize_title">Un exercice est encore en cours</string>
<string name="exercise_timeline_finalize_detail">%1$s est encore actif. Le terminer maintenant puis enregistrer la séance ?</string>
<string name="exercise_timeline_finalize_confirm">Terminer et enregistrer</string>
<string name="sleep_night_capture_title">Saisie nocturne</string>
<string name="sleep_no_night_active">Aucune nuit en cours.</string>
<string name="sleep_night_in_progress">Nuit en cours · %1$s → %2$s</string>
<string name="sleep_quick_bed">🛏 COUCHÉ</string>
<string name="sleep_quick_wake">👁 RÉVEIL</string>
<string name="sleep_quick_get_up">☀ LEVÉ</string>
<string name="sleep_bed_recorded">Couché enregistré.</string>
<string name="sleep_wake_recorded">Réveil enregistré.</string>
<string name="sleep_get_up_recorded">Levé enregistré.</string>
<string name="sleep_medication_recorded">%1$s enregistré.</string>
<string name="sleep_undo">ANNULER</string>
<string name="sleep_action_undone">Dernière action annulée.</string>
<string name="sleep_undo_unavailable">Annulation impossible : la nuit a déjà changé.</string>
<string name="sleep_correct_night">Corriger la nuit</string>
<string name="sleep_return_night_mode">Retour à la saisie nocturne</string>
<string name="sleep_night_timeline">Chronologie de la nuit</string>
<string name="sleep_quick_no_active">Aucune nuit en cours.</string>
<string name="sleep_quick_already_active">Une nuit est déjà en cours.</string>
<string name="sleep_quick_medication_unavailable">Médicament indisponible.</string>
<string name="sleep_quick_conflict">La nuit a changé ; rechargez avant de recommencer.</string>
<string name="sleep_quick_error">Enregistrement impossible.</string>
</resources>

View file

@ -0,0 +1,151 @@
package com.labfytools.trainlog.data
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.SleepEventType
import com.labfytools.trainlog.model.SleepMedication
import java.util.UUID
import org.json.JSONObject
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 SleepQuickCaptureTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun oneTapNightRecordsMedicationWakesAndSleepHeartRate() {
val name = "sleep-quick-" + UUID.randomUUID()
val repository = TrainlogRepository(context, name)
try {
val medicationCreated =
repository.saveSleepMedication(
SleepMedication(
medicationId = "",
revisionId = "",
createdAt = "2026-09-23T20:00:00+02:00",
updatedAt = "2026-09-23T20:00:00+02:00",
name = "Test medication",
defaultDoseValue = 5.0,
defaultDoseUnit = "mg",
form = "",
note = "",
active = true,
),
) as TrainlogRepository.SaveSleepDiaryResult.Saved
val medication =
repository.listSleepMedications(false).single {
it.medicationId == medicationCreated.entryId
}
val bed =
repository.quickSleepBedTime("2026-09-23T22:30:00+02:00")
as TrainlogRepository.SleepQuickActionResult.Applied
assertEquals("2026-09-23", repository.listSleepDiary().single().nightStartDate)
val measurement =
ParsedHeartRateMeasurement(
bpm = 62,
sensorContactDetected = null,
energyExpended = null,
rrIntervals1024 = listOf(1000),
)
assertTrue(
repository.recordLiveHeartRateForActiveSleep(
"CYCPLUS H2",
"2026-09-23T22:30:01+02:00",
measurement,
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
val captureId = checkNotNull(repository.activeHeartRateCapture()).captureId
assertTrue(
repository.quickSleepMedication(
medication.medicationId,
"2026-09-23T22:35:00+02:00",
) is TrainlogRepository.SleepQuickActionResult.Applied,
)
assertTrue(
repository.quickSleepWake("2026-09-24T02:17:00+02:00")
is TrainlogRepository.SleepQuickActionResult.Applied,
)
assertTrue(
repository.quickSleepWake("2026-09-24T04:42:00+02:00")
is TrainlogRepository.SleepQuickActionResult.Applied,
)
val beforeGetUp = repository.listSleepDiary().single()
assertEquals(bed.receipt.entryId, beforeGetUp.entryId)
assertEquals(
2,
beforeGetUp.events.count { it.type == SleepEventType.NIGHT_GET_UP },
)
assertEquals(1, beforeGetUp.intakes.size)
assertEquals(5.0, checkNotNull(beforeGetUp.intakes.single().doseValue), 0.0)
assertTrue(
repository.quickSleepFinalGetUp("2026-09-24T06:31:00+02:00")
is TrainlogRepository.SleepQuickActionResult.Applied,
)
assertNull(repository.activeHeartRateCapture())
val finalEntry = repository.listSleepDiary().single()
assertEquals(
1,
finalEntry.events.count { it.type == SleepEventType.FINAL_GET_UP },
)
val exported = JSONObject(repository.buildHeartRateV1Json())
val capture = exported.getJSONArray("captures").getJSONObject(0)
assertEquals(captureId, capture.getString("capture_id"))
assertEquals("sleep", capture.getString("context_kind"))
assertEquals(finalEntry.entryId, capture.getString("context_id"))
assertEquals(62, capture.getJSONArray("samples").getJSONObject(0).getInt("bpm"))
} finally {
repository.close()
context.deleteDatabase(name)
}
}
@Test
fun undoIsGuardedByTheExactAppliedRevision() {
val name = "sleep-undo-" + UUID.randomUUID()
val repository = TrainlogRepository(context, name)
try {
repository.quickSleepBedTime("2026-09-23T22:30:00+02:00")
val firstWake =
repository.quickSleepWake("2026-09-24T02:00:00+02:00")
as TrainlogRepository.SleepQuickActionResult.Applied
val secondWake =
repository.quickSleepWake("2026-09-24T03:00:00+02:00")
as TrainlogRepository.SleepQuickActionResult.Applied
assertTrue(
repository.undoSleepQuickAction(firstWake.receipt)
is TrainlogRepository.SleepQuickActionResult.Conflict,
)
assertTrue(
repository.undoSleepQuickAction(
secondWake.receipt,
"2026-09-24T03:00:05+02:00",
) is TrainlogRepository.SleepQuickActionResult.Applied,
)
val night = repository.listSleepDiary().single()
assertEquals(
listOf("2026-09-24T02:00:00+02:00"),
night.events
.filter { it.type == SleepEventType.NIGHT_GET_UP }
.map { it.startAt },
)
} finally {
repository.close()
context.deleteDatabase(name)
}
}
}

View file

@ -151,7 +151,8 @@ parallel implementation of its rules.
| Cardio BLE diagnostics | `TRAINLOG_CARDIO_BLE_DIAGNOSTICS_V1=PASS`: real CYCPLUS H2 validated on Android; `0x180D` + `0x2A37`, Battery Service, approximately 1 Hz notifications and one raw RR interval per observed notification; contact and energy fields absent in the observed payloads |
| Cardio persistent acquisition | `TRAINLOG_CARDIO_PERSISTENT_ACQUISITION_V1=PASS`: selected sensor persists, dedicated connected-device foreground service reconnects, stale BPM is cleared, and the global Android `♥ BPM` indicator is grey/orange/green by connection state; real CYCPLUS restart and Bluetooth-off/on recovery validated on private vc40 |
| 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 |
| Current operational cursor | `TRAINLOG_SLEEP_ANDROID_ONE_TAP_V1`: add the Android Sleep entry point with one-tap Couché / medication / Réveil / Levé, immediate undo, later correction, and sleep-linked cardio capture |
| 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 |
| Current operational cursor | `TRAINLOG_CARDIO_SESSION_TYPE_V1`: add the true `cardio` session type without silently widening frozen legacy formats |
| 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

@ -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_SLEEP_ANDROID_ONE_TAP_V1
CURRENT_OPERATIONAL_CURSOR=TRAINLOG_CARDIO_SESSION_TYPE_V1
```
The focused `TRAINLOG_SYNC_CAUSAL_DELETE_V1_CLOSEOUT` is complete before this

View file

@ -1744,3 +1744,29 @@ The 2026-09-23 closeout passes the complete native Meson inventory:
**104/104 passed, 0 failed**. The complete Android JVM inventory reports
**272 tests: 267 passed, five skipped, zero failures/errors**; `assembleDebug`
also succeeds. The five skips remain optional historical/external fixture gates.
## Android one-tap Sleep V1 validation
`TRAINLOG_SLEEP_ANDROID_ONE_TAP_V1=SOFTWARE_PASS / DEVICE_VISUAL_PENDING`
reuses the existing Sleep Diary V1 revision model and schema. The quick-capture
API creates/advances normal immutable Sleep Diary revisions for Couché,
medication intake, point Réveil and Levé actions. Undo is accepted only when
the action's exact applied revision is still current; older receipts conflict,
and successful undo appends a compensating revision instead of rewriting the
history.
Targeted coverage executes Couché, a configured medication with dose snapshot,
two independent wake points, live sleep-owned BPM/RR capture, Levé and final
heart-rate export against the same stable sleep `entry_id`. A separate test
proves stale undo rejection and exact restoration of the latest action.
The complete Android JVM inventory before the final localization-only cleanup
reported **274 tests: 269 passed, five skipped, zero failures/errors** and
`assembleDebug` succeeded. After the localization cleanup the targeted
SleepQuickCapture test and `assembleDebug` passed again.
The private Android installation was advanced non-destructively to Trainlog
0.1.5 versionCode 41 with the same debug signing identity and no uninstall or
data clear. The device locked before the final visual Sleep-screen inspection,
so no lock bypass was attempted and no real Sleep business button was pressed;
that visual review remains explicitly pending.