From 070a29298f44a13b37d3b1c1497241c8e6ec2a8a Mon Sep 17 00:00:00 2001 From: fy59 Date: Wed, 23 Sep 2026 11:47:32 +0200 Subject: [PATCH] feat(android): add one-tap sleep capture --- CHANGELOG.md | 6 + .../trainlog/data/HeartRateSensorService.kt | 10 +- .../trainlog/data/TrainlogRepository.kt | 266 ++++++++++++++++++ .../trainlog/ui/SleepDiaryScreen.kt | 233 +++++++++++++++ .../app/src/main/res/values-en/strings.xml | 23 ++ android/app/src/main/res/values/strings.xml | 23 ++ .../trainlog/data/SleepQuickCaptureTest.kt | 151 ++++++++++ docs/current_state.md | 3 +- docs/roadmap.md | 2 +- docs/tests.md | 26 ++ 10 files changed, 739 insertions(+), 4 deletions(-) create mode 100644 android/app/src/test/java/com/labfytools/trainlog/data/SleepQuickCaptureTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 840b2d5..43d13a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/HeartRateSensorService.kt b/android/app/src/main/java/com/labfytools/trainlog/data/HeartRateSensorService.kt index 12b26e1..5f3c909 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/HeartRateSensorService.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/HeartRateSensorService.kt @@ -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) { 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 0b03991..53c021a 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 @@ -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? = + 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 = this.events, + intakes: List = 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 { require(limit in 1..3660) val db = database.readableDatabase diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/SleepDiaryScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/SleepDiaryScreen.kt index c27de82..43d3582 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/SleepDiaryScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/SleepDiaryScreen.kt @@ -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(null) + } + var message by remember { mutableStateOf(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(repository.listSleepDiary().firstOrNull()) } val today = LocalDate.now() diff --git a/android/app/src/main/res/values-en/strings.xml b/android/app/src/main/res/values-en/strings.xml index 76a56f0..f98a236 100644 --- a/android/app/src/main/res/values-en/strings.xml +++ b/android/app/src/main/res/values-en/strings.xml @@ -536,4 +536,27 @@ An exercise is still active %1$s is still active. Finish it now and then save the session? Finish and save + + Night capture + No night in progress. + Night in progress · %1$s → %2$s + 🛏 BED + 👁 WAKE + ☀ GET UP + Bed time recorded. + Wake recorded. + Get-up recorded. + %1$s recorded. + UNDO + Last action undone. + Undo unavailable: the night has already changed. + Correct the night + Back to night capture + Night timeline + + No night in progress. + A night is already in progress. + Medication unavailable. + The night changed; reload before trying again. + Unable to record the action. diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index f51ba53..6e1f4a6 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -546,4 +546,27 @@ Un exercice est encore en cours %1$s est encore actif. Le terminer maintenant puis enregistrer la séance ? Terminer et enregistrer + + Saisie nocturne + Aucune nuit en cours. + Nuit en cours · %1$s → %2$s + 🛏 COUCHÉ + 👁 RÉVEIL + ☀ LEVÉ + Couché enregistré. + Réveil enregistré. + Levé enregistré. + %1$s enregistré. + ANNULER + Dernière action annulée. + Annulation impossible : la nuit a déjà changé. + Corriger la nuit + Retour à la saisie nocturne + Chronologie de la nuit + + Aucune nuit en cours. + Une nuit est déjà en cours. + Médicament indisponible. + La nuit a changé ; rechargez avant de recommencer. + Enregistrement impossible. diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/SleepQuickCaptureTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/SleepQuickCaptureTest.kt new file mode 100644 index 0000000..06f6199 --- /dev/null +++ b/android/app/src/test/java/com/labfytools/trainlog/data/SleepQuickCaptureTest.kt @@ -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) + } + } +} diff --git a/docs/current_state.md b/docs/current_state.md index 4def2cb..168cce9 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -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` | diff --git a/docs/roadmap.md b/docs/roadmap.md index 51da3df..239fc7f 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_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 diff --git a/docs/tests.md b/docs/tests.md index 65c07f5..cc485c2 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -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.