diff --git a/CHANGELOG.md b/CHANGELOG.md index fed9e8c..48915bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ medication catalog and timestamped intake snapshots with optional positive dose/unit, synchronized and rendered factually in the agenda and PDF without efficacy, interaction, causality, dosage, or clinical advice. + Progressive daily capture now persists every edit locally while keeping + draft, ready, synchronized, and post-sync modified states distinct. Explicit + validation publishes only the guarded current revision through the existing + full-generation/ACK path; validation never makes a day immutable. Development opened after stable v0.1.3 release. diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationService.kt b/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationService.kt index 4e578ed..c57d15f 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationService.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationService.kt @@ -412,6 +412,16 @@ internal class SyncGenerationService(private val repository: TrainlogRepository) arrayOf(operationId, generationId, if (existed) 0 else 1), ) } + val sleep = JSONObject(checkNotNull(artifacts["sleep-diary"])) + val sleepEntries = sleep.getJSONArray("entries") + for (index in 0 until sleepEntries.length()) { + val entry = sleepEntries.getJSONObject(index) + db.execSQL( + "INSERT INTO sleep_diary_generation_entries(generation_id,entry_id,revision_id) " + + "VALUES(?,?,?)", + arrayOf(generationId, entry.getString("entry_id"), entry.getString("revision_id")), + ) + } } return CapturedSyncGeneration(generationId, runId, digest, stage) } catch (error: Exception) { @@ -1039,6 +1049,17 @@ internal class SyncGenerationService(private val repository: TrainlogRepository) ack.getString("generation_id"), ), ) + if (target == "acknowledged") { + db.execSQL( + "UPDATE sleep_diary_publication_state SET acknowledged_revision_id=(SELECT " + + "m.revision_id FROM sleep_diary_generation_entries m WHERE " + + "m.generation_id=? AND m.entry_id=sleep_diary_publication_state.entry_id)," + + "acknowledged_at=? WHERE EXISTS(SELECT 1 FROM sleep_diary_generation_entries m " + + "WHERE m.generation_id=? AND m.entry_id=sleep_diary_publication_state.entry_id)", + arrayOf(ack.getString("generation_id"), ack.getString("consumed_at"), + ack.getString("generation_id")), + ) + } target } } 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 3f2d5c4..09b0b00 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 @@ -45,6 +45,7 @@ import com.labfytools.trainlog.model.SyncedProgramSummary import com.labfytools.trainlog.model.SleepDiaryDraft import com.labfytools.trainlog.model.SleepDiaryEntry import com.labfytools.trainlog.model.SleepDiaryEvent +import com.labfytools.trainlog.model.SleepPublicationStatus import com.labfytools.trainlog.model.MedicationIntake import com.labfytools.trainlog.model.SleepMedication import com.labfytools.trainlog.model.SleepEventType @@ -563,7 +564,11 @@ class TrainlogRepository( fun listSleepDiary(limit: Int = 90): List { require(limit in 1..3660) val db = database.readableDatabase - return db.rawQuery("SELECT entry_id,night_start_date,night_end_date,created_at,updated_at,current_revision_id FROM sleep_diary_entries WHERE deleted=0 ORDER BY night_start_date DESC,entry_id LIMIT ?", arrayOf(limit.toString())).use { cursor -> + return db.rawQuery("SELECT e.entry_id,e.night_start_date,e.night_end_date,e.created_at," + + "e.updated_at,e.current_revision_id,p.validated_revision_id,p.acknowledged_revision_id " + + "FROM sleep_diary_entries e LEFT JOIN sleep_diary_publication_state p ON " + + "p.entry_id=e.entry_id WHERE e.deleted=0 ORDER BY e.night_start_date DESC,e.entry_id LIMIT ?", + arrayOf(limit.toString())).use { cursor -> buildList { while (cursor.moveToNext()) { val revision = cursor.getString(5) @@ -571,12 +576,41 @@ class TrainlogRepository( 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(revision)).use { eventCursor -> buildList { while (eventCursor.moveToNext()) add(SleepDiaryEvent(eventCursor.getString(0), SleepEventType.entries.single { it.wireValue == eventCursor.getString(1) }, eventCursor.getString(2), if (eventCursor.isNull(3)) null else eventCursor.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(revision)).use { intakeCursor -> buildList { while (intakeCursor.moveToNext()) add(MedicationIntake(intakeCursor.getString(0), intakeCursor.getString(1), intakeCursor.getString(2), intakeCursor.getString(3), if (intakeCursor.isNull(4)) null else intakeCursor.getDouble(4), if (intakeCursor.isNull(5)) null else intakeCursor.getString(5), intakeCursor.getString(6), intakeCursor.getString(7))) } } fun quality(value: String?) = value?.let { text -> SleepQuality.entries.single { it.wireValue == text } } - add(SleepDiaryEntry(cursor.getString(0),cursor.getString(1),cursor.getString(2),cursor.getString(3),cursor.getString(4),revision,quality(values[0]),quality(values[1]),quality(values[2]),values[3].orEmpty(),events,intakes)) + val validated = if (cursor.isNull(6)) null else cursor.getString(6) + val acknowledged = if (cursor.isNull(7)) null else cursor.getString(7) + val status = when { + acknowledged == revision -> SleepPublicationStatus.SYNCHRONIZED + acknowledged != null -> SleepPublicationStatus.MODIFIED + validated == revision -> SleepPublicationStatus.READY + else -> SleepPublicationStatus.DRAFT + } + add(SleepDiaryEntry(cursor.getString(0),cursor.getString(1),cursor.getString(2),cursor.getString(3),cursor.getString(4),revision,quality(values[0]),quality(values[1]),quality(values[2]),values[3].orEmpty(),events,intakes,status)) } } } } + /** Marks only the guarded local tip publishable; transport remains owned + * by the existing full-generation synchronization action. */ + fun validateSleepDiary(entryId: String, expectedRevision: String, validatedAt: String): SaveSleepDiaryResult { + if (!sleepIdentity(entryId, "sl") || !sleepIdentity(expectedRevision, "slr") || + try { OffsetDateTime.parse(validatedAt); false } catch (_: RuntimeException) { true }) { + return SaveSleepDiaryResult.Invalid + } + val statement = database.writableDatabase.compileStatement( + "INSERT INTO sleep_diary_publication_state(entry_id,validated_revision_id,validated_at) " + + "SELECT entry_id,current_revision_id,? FROM sleep_diary_entries WHERE entry_id=? " + + "AND current_revision_id=? AND deleted=0 ON CONFLICT(entry_id) DO UPDATE SET " + + "validated_revision_id=excluded.validated_revision_id,validated_at=excluded.validated_at", + ) + statement.bindString(1, validatedAt); statement.bindString(2, entryId) + statement.bindString(3, expectedRevision) + return try { + if (statement.executeInsert() < 0L) SaveSleepDiaryResult.Conflict + else SaveSleepDiaryResult.Saved(entryId, expectedRevision) + } catch (_: android.database.sqlite.SQLiteException) { SaveSleepDiaryResult.Conflict } + } + fun listSleepMedications(includeInactive: Boolean = true): List { val db = database.readableDatabase return db.rawQuery( @@ -670,6 +704,12 @@ class TrainlogRepository( guarded.bindString(3, entryId) guarded.bindString(4, expectedRevision) if (guarded.executeUpdateDelete() != 1) return SaveSleepDiaryResult.Conflict + db.execSQL( + "INSERT INTO sleep_diary_publication_state(entry_id,validated_revision_id,validated_at) " + + "VALUES(?,?,?) ON CONFLICT(entry_id) DO UPDATE SET " + + "validated_revision_id=excluded.validated_revision_id,validated_at=excluded.validated_at", + arrayOf(entryId, revisionId, deletedAt), + ) db.setTransactionSuccessful() SaveSleepDiaryResult.Saved(entryId, revisionId) } catch (_: android.database.sqlite.SQLiteException) { @@ -684,8 +724,10 @@ class TrainlogRepository( val entries = JSONArray() val db = database.readableDatabase db.rawQuery( - "SELECT entry_id,night_start_date,night_end_date,created_at,updated_at,current_revision_id,deleted " + - "FROM sleep_diary_entries ORDER BY entry_id", + "SELECT e.entry_id,e.night_start_date,e.night_end_date,e.created_at,e.updated_at," + + "e.current_revision_id,e.deleted FROM sleep_diary_entries e JOIN " + + "sleep_diary_publication_state p ON p.entry_id=e.entry_id AND " + + "p.validated_revision_id=e.current_revision_id ORDER BY e.entry_id", null, ).use { cursor -> while (cursor.moveToNext()) { @@ -881,6 +923,7 @@ class TrainlogRepository( if (!sameEvents) return SleepDiaryImportResult.Rejected( "sleep diary revision identity reused with different content", ) + markSleepDiaryImported(db, entryId, revisionId, root.getString("generated_at")) unchanged++ continue } @@ -936,6 +979,7 @@ class TrainlogRepository( intake.medicationName, intake.takenAt, intake.doseValue, intake.doseUnit, intake.note, intake.createdAt), ) } + markSleepDiaryImported(db, entryId, revisionId, root.getString("generated_at")) advanced++ } SleepDiaryImportResult.Applied(advanced, unchanged) @@ -944,6 +988,22 @@ class TrainlogRepository( } } + private fun markSleepDiaryImported( + db: SQLiteDatabase, + entryId: String, + revisionId: String, + synchronizedAt: String, + ) { + db.execSQL( + "INSERT INTO sleep_diary_publication_state(entry_id,validated_revision_id,validated_at," + + "acknowledged_revision_id,acknowledged_at) VALUES(?,?,?,?,?) ON CONFLICT(entry_id) " + + "DO UPDATE SET validated_revision_id=excluded.validated_revision_id," + + "validated_at=excluded.validated_at,acknowledged_revision_id=excluded.acknowledged_revision_id," + + "acknowledged_at=excluded.acknowledged_at", + arrayOf(entryId, revisionId, synchronizedAt, revisionId, synchronizedAt), + ) + } + /** Create a transactionally consistent SQLite snapshot without exposing WAL files. */ internal fun backupDatabaseTo(destination: File) { require(!destination.exists()) { "Backup snapshot destination already exists." } @@ -9602,6 +9662,7 @@ private class TrainlogDatabaseHelper( )""".trimIndent(), ) db.execSQL("CREATE INDEX IF NOT EXISTS sleep_diary_entries_dates ON sleep_diary_entries(night_start_date,deleted)") + db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS sleep_diary_one_night ON sleep_diary_entries(night_start_date)") db.execSQL("CREATE INDEX IF NOT EXISTS sleep_diary_events_time ON sleep_diary_events(start_at,end_at)") db.execSQL("""CREATE TABLE IF NOT EXISTS sleep_medications( medication_id TEXT PRIMARY KEY, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, @@ -9624,6 +9685,17 @@ private class TrainlogDatabaseHelper( (dose_value>0 AND length(trim(dose_unit)) BETWEEN 1 AND 32)))""".trimIndent()) db.execSQL("CREATE INDEX IF NOT EXISTS sleep_medications_active ON sleep_medications(deleted,updated_at)") db.execSQL("CREATE INDEX IF NOT EXISTS sleep_medication_intakes_time ON sleep_medication_intakes(taken_at,medication_id)") + db.execSQL("""CREATE TABLE IF NOT EXISTS sleep_diary_publication_state( + entry_id TEXT PRIMARY KEY REFERENCES sleep_diary_entries(entry_id) ON DELETE RESTRICT, + validated_revision_id TEXT, validated_at TEXT, acknowledged_revision_id TEXT, + acknowledged_at TEXT, + CHECK((validated_revision_id IS NULL)=(validated_at IS NULL)), + CHECK((acknowledged_revision_id IS NULL)=(acknowledged_at IS NULL)))""".trimIndent()) + db.execSQL("""CREATE TABLE IF NOT EXISTS sleep_diary_generation_entries( + generation_id TEXT NOT NULL REFERENCES sync_generations(generation_id) ON DELETE RESTRICT, + entry_id TEXT NOT NULL REFERENCES sleep_diary_entries(entry_id) ON DELETE RESTRICT, + revision_id TEXT NOT NULL REFERENCES sleep_diary_revisions(revision_id) ON DELETE RESTRICT, + PRIMARY KEY(generation_id,entry_id))""".trimIndent()) } private fun createSyncDataLifecycleTables(db: SQLiteDatabase) { diff --git a/android/app/src/main/java/com/labfytools/trainlog/model/SleepDiaryModels.kt b/android/app/src/main/java/com/labfytools/trainlog/model/SleepDiaryModels.kt index 410de06..8070d43 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/model/SleepDiaryModels.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/model/SleepDiaryModels.kt @@ -6,6 +6,7 @@ enum class SleepEventType(val wireValue: String) { SLEEP("sleep"), NAP("nap"), LONG_AWAKE("long_awake"), HALF_SLEEP("half_sleep"), DAYTIME_SLEEPINESS("daytime_sleepiness"), } +enum class SleepPublicationStatus { DRAFT, READY, SYNCHRONIZED, MODIFIED } data class SleepDiaryEvent(val eventId: String, val type: SleepEventType, val startAt: String, val endAt: String?) data class SleepMedication( val medicationId: String, val revisionId: String, val createdAt: String, val updatedAt: String, @@ -22,6 +23,7 @@ data class SleepDiaryEntry( val createdAt: String, val updatedAt: String, val revisionId: String, val sleepQuality: SleepQuality?, val wakeQuality: SleepQuality?, val dayForm: SleepQuality?, val treatmentAndNotes: String, val events: List, val intakes: List, + val publicationStatus: SleepPublicationStatus, ) data class SleepDiaryDraft( val entryId: String? = null, val expectedRevision: String? = null, 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 fb0e233..c27de82 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 @@ -14,6 +14,7 @@ import androidx.compose.material3.FilterChip import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -29,10 +30,12 @@ import com.labfytools.trainlog.model.SleepDiaryEntry import com.labfytools.trainlog.model.SleepDiaryEvent import com.labfytools.trainlog.model.SleepEventType import com.labfytools.trainlog.model.SleepQuality +import com.labfytools.trainlog.model.SleepPublicationStatus import com.labfytools.trainlog.model.MedicationIntake import com.labfytools.trainlog.model.SleepMedication import java.time.LocalDate import java.time.OffsetDateTime +import java.util.UUID @Composable fun SleepDiaryScreen(repository: TrainlogRepository) { @@ -56,7 +59,22 @@ fun SleepDiaryScreen(repository: TrainlogRepository) { var medicationDose by remember { mutableStateOf("") } var medicationUnit by remember { mutableStateOf("mg") } var editingMedication by remember { mutableStateOf(null) } - val entries = remember(revision) { repository.listSleepDiary() } + var editSerial by remember { mutableStateOf(0) } + val entries = repository.listSleepDiary() + fun changed() { editSerial++ } + + LaunchedEffect(editSerial) { + if (editSerial > 0) { + val now = OffsetDateTime.now().toString() + val current = selected + val result = repository.saveSleepDiary(SleepDiaryDraft(current?.entryId, + current?.revisionId,startDate,endDate,current?.createdAt ?: now,now,sleepQuality, + wakeQuality,dayForm,notes,events,intakes)) + if (result is TrainlogRepository.SaveSleepDiaryResult.Saved) { + selected = repository.listSleepDiary().firstOrNull { it.entryId == result.entryId } + } + } + } LazyColumn( modifier = Modifier.fillMaxSize().padding(12.dp), @@ -67,6 +85,12 @@ fun SleepDiaryScreen(repository: TrainlogRepository) { Card(onClick = { selected = entry }) { Column(Modifier.fillMaxWidth().padding(12.dp)) { Text("${entry.nightStartDate} → ${entry.nightEndDate}") + Text(stringResource(when (entry.publicationStatus) { + SleepPublicationStatus.DRAFT -> R.string.sleep_status_draft + SleepPublicationStatus.READY -> R.string.sleep_status_ready + SleepPublicationStatus.SYNCHRONIZED -> R.string.sleep_status_synchronized + SleepPublicationStatus.MODIFIED -> R.string.sleep_status_modified + })) Text("${entry.events.size} · ${entry.sleepQuality?.wireValue ?: "—"} / ${entry.wakeQuality?.wireValue ?: "—"}") } } @@ -76,18 +100,19 @@ fun SleepDiaryScreen(repository: TrainlogRepository) { Column(Modifier.fillMaxWidth().padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Text(stringResource(R.string.sleep_morning)) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedTextField(startDate, { startDate = it }, label = { Text("YYYY-MM-DD") }, modifier = Modifier.weight(1f)) - OutlinedTextField(endDate, { endDate = it }, label = { Text("YYYY-MM-DD") }, modifier = Modifier.weight(1f)) + OutlinedTextField(startDate, { startDate = it; changed() }, label = { Text("YYYY-MM-DD") }, modifier = Modifier.weight(1f)) + OutlinedTextField(endDate, { endDate = it; changed() }, label = { Text("YYYY-MM-DD") }, modifier = Modifier.weight(1f)) } - QualityRow(stringResource(R.string.sleep_quality), sleepQuality) { sleepQuality = it } - QualityRow(stringResource(R.string.sleep_wake_quality), wakeQuality) { wakeQuality = it } + QualityRow(stringResource(R.string.sleep_quality), sleepQuality) { sleepQuality = it; changed() } + QualityRow(stringResource(R.string.sleep_wake_quality), wakeQuality) { wakeQuality = it; changed() } Text(stringResource(R.string.sleep_day)) - QualityRow(stringResource(R.string.sleep_day_form), dayForm) { dayForm = it } + QualityRow(stringResource(R.string.sleep_day_form), dayForm) { dayForm = it; changed() } SleepEventType.entries.forEach { type -> Button(onClick = { val start = OffsetDateTime.now() - events = events + SleepDiaryEvent("", type, start.toString(), + events = events + SleepDiaryEvent("sle_${UUID.randomUUID()}", type, start.toString(), if (type in pointTypes) null else start.plusHours(1).toString()) + changed() }) { Text(eventLabel(type)) } } events.forEachIndexed { index, event -> @@ -98,17 +123,19 @@ fun SleepDiaryScreen(repository: TrainlogRepository) { events = events.mapIndexed { item, current -> if (item == index) current.copy(startAt = value) else current } + changed() } - OutlinedTextField(event.startAt, { value -> events = events.mapIndexed { item, current -> if (item == index) current.copy(startAt = value) else current } }, label = { Text("ISO-8601") }) + OutlinedTextField(event.startAt, { value -> events = events.mapIndexed { item, current -> if (item == index) current.copy(startAt = value) else current }; changed() }, label = { Text("ISO-8601") }) event.endAt?.let { end -> TimeButton(end) { value -> events = events.mapIndexed { item, current -> if (item == index) current.copy(endAt = value) else current } + changed() } - OutlinedTextField(end, { value -> events = events.mapIndexed { item, current -> if (item == index) current.copy(endAt = value) else current } }, label = { Text("ISO-8601 →") }) + OutlinedTextField(end, { value -> events = events.mapIndexed { item, current -> if (item == index) current.copy(endAt = value) else current }; changed() }, label = { Text("ISO-8601 →") }) } - Button(onClick = { events = events.filterIndexed { item, _ -> item != index } }) { Text(stringResource(R.string.sleep_delete)) } + Button(onClick = { events = events.filterIndexed { item, _ -> item != index }; changed() }) { Text(stringResource(R.string.sleep_delete)) } } } } @@ -162,9 +189,10 @@ fun SleepDiaryScreen(repository: TrainlogRepository) { val dose = intakeDose.toDoubleOrNull() if (intakeDose.isEmpty() || dose != null && dose > 0.0) { val now = OffsetDateTime.now().toString() - intakes = intakes + MedicationIntake("", medication.medicationId, + intakes = intakes + MedicationIntake("mdi_${UUID.randomUUID()}", medication.medicationId, medication.name, now, dose, if (dose == null) null else intakeUnit, "", now) + changed() } } }) { Text(stringResource(R.string.sleep_add_intake)) } @@ -172,28 +200,35 @@ fun SleepDiaryScreen(repository: TrainlogRepository) { Column(Modifier.fillMaxWidth().padding(8.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { Text(intake.medicationName) TimeButton(intake.takenAt) { value -> intakes = intakes.mapIndexed { item, current -> - if (item == index) current.copy(takenAt = value) else current } } + if (item == index) current.copy(takenAt = value) else current }; changed() } Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { OutlinedTextField(intake.doseValue?.toString().orEmpty(), { value -> intakes = intakes.mapIndexed { item, current -> if (item == index) current.copy( doseValue = value.toDoubleOrNull(), doseUnit = if (value.isEmpty()) null else current.doseUnit ?: "mg") else current } + changed() }, label = { Text(stringResource(R.string.sleep_dose)) }, modifier = Modifier.weight(1f)) OutlinedTextField(intake.doseUnit.orEmpty(), { value -> intakes = intakes.mapIndexed { item, current -> if (item == index) current.copy(doseUnit = value) else current } + changed() }, label = { Text(stringResource(R.string.sleep_unit)) }, modifier = Modifier.weight(1f)) } - Button(onClick = { intakes = intakes.filterIndexed { item, _ -> item != index } }) { + Button(onClick = { intakes = intakes.filterIndexed { item, _ -> item != index }; changed() }) { Text(stringResource(R.string.sleep_delete)) } } } } - OutlinedTextField(notes, { notes = it }, label = { Text(stringResource(R.string.sleep_notes)) }, modifier = Modifier.fillMaxWidth(), minLines = 3) + OutlinedTextField(notes, { notes = it; changed() }, label = { Text(stringResource(R.string.sleep_notes)) }, modifier = Modifier.fillMaxWidth(), minLines = 3) Button(onClick = { val now = OffsetDateTime.now().toString() val current = selected val result = repository.saveSleepDiary(SleepDiaryDraft(current?.entryId,current?.revisionId,startDate,endDate,current?.createdAt ?: now,now,sleepQuality,wakeQuality,dayForm,notes,events,intakes)) if (result is TrainlogRepository.SaveSleepDiaryResult.Saved) { revision++; selected = repository.listSleepDiary().firstOrNull { it.entryId == result.entryId } } }) { Text(stringResource(R.string.sleep_save)) } + selected?.let { entry -> Button(onClick = { + repository.validateSleepDiary(entry.entryId, entry.revisionId, + OffsetDateTime.now().toString()) + selected = repository.listSleepDiary().firstOrNull { it.entryId == entry.entryId } + }) { Text(stringResource(R.string.sleep_validate)) } } selected?.let { entry -> Button(onClick = { repository.deleteSleepDiary(entry.entryId,entry.revisionId,OffsetDateTime.now().toString()); selected = null; revision++ }) { Text(stringResource(R.string.sleep_delete)) } } } } diff --git a/android/app/src/main/res/values-en/strings.xml b/android/app/src/main/res/values-en/strings.xml index b23e7bf..8dab40c 100644 --- a/android/app/src/main/res/values-en/strings.xml +++ b/android/app/src/main/res/values-en/strings.xml @@ -26,6 +26,11 @@ Treatment and notes Add event Save + Validate day + Draft + Ready to sync + Synchronized + Modified since synchronization Delete Choose time Current session diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index f4d968a..70dc13c 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -26,6 +26,11 @@ Traitement et remarques Ajouter un événement Enregistrer + Valider la journée + Brouillon + Prête à synchroniser + Synchronisée + Modifiée depuis la synchronisation Supprimer Choisir l’heure Séance en cours diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/SleepDiaryRepositoryTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/SleepDiaryRepositoryTest.kt index 62babfd..8e49047 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/SleepDiaryRepositoryTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/SleepDiaryRepositoryTest.kt @@ -6,6 +6,7 @@ import com.labfytools.trainlog.model.SleepDiaryDraft import com.labfytools.trainlog.model.SleepDiaryEvent import com.labfytools.trainlog.model.SleepEventType import com.labfytools.trainlog.model.SleepQuality +import com.labfytools.trainlog.model.SleepPublicationStatus import com.labfytools.trainlog.model.MedicationIntake import com.labfytools.trainlog.model.SleepMedication import java.util.UUID @@ -39,6 +40,15 @@ class SleepDiaryRepositoryTest { "Synthetic medication", "2026-10-25T05:15:00+01:00", 10.0, "mg", "", catalogTime))) val created = repository.saveSleepDiary(initial) as TrainlogRepository.SaveSleepDiaryResult.Saved val loaded = repository.listSleepDiary().single() + assertEquals(SleepPublicationStatus.DRAFT, loaded.publicationStatus) + assertEquals(0, org.json.JSONObject(repository.buildSleepDiaryV1Json()) + .getJSONArray("entries").length()) + assertTrue(repository.validateSleepDiary(loaded.entryId, loaded.revisionId, + "2026-10-25T17:30:00+01:00") is TrainlogRepository.SaveSleepDiaryResult.Saved) + assertEquals(SleepPublicationStatus.READY, + repository.listSleepDiary().single().publicationStatus) + assertEquals(1, org.json.JSONObject(repository.buildSleepDiaryV1Json()) + .getJSONArray("entries").length()) assertEquals(2, loaded.events.size) assertEquals(10.0, loaded.intakes.single().doseValue!!, 0.0) val medication = repository.listSleepMedications().single() @@ -56,6 +66,7 @@ class SleepDiaryRepositoryTest { repository.close(); repository = TrainlogRepository(context, name) val reopened = repository.listSleepDiary().single() assertEquals(SleepQuality.MOY, reopened.wakeQuality) + assertEquals(SleepPublicationStatus.DRAFT, reopened.publicationStatus) assertTrue(repository.deleteSleepDiary(reopened.entryId, reopened.revisionId, "2026-10-26T12:00:00+01:00") is TrainlogRepository.SaveSleepDiaryResult.Saved) assertTrue(repository.listSleepDiary().isEmpty()) diff --git a/docs/android.md b/docs/android.md index 712508b..5276f8b 100644 --- a/docs/android.md +++ b/docs/android.md @@ -12,6 +12,11 @@ local-first field capture. Its compact Morning and Day sections edit one durable night/day entry without reproducing the desktop/PDF grid. Creation, correction and logical deletion are revision guarded and survive repository reopen. Full-generation exchange transports the companion, never SQLite. +Each event, intake, appreciation, date or note edit is saved locally at once; +closing the screen or killing the process does not require validation or sync. +The screen resumes the most recent entry and distinguishes draft, ready, +synchronized and modified tips. “Validate day” marks the current revision for +the existing global synchronization action and never makes it immutable. ## Background USB and private Drive synchronization diff --git a/docs/database.md b/docs/database.md index 04b1357..e7f87c4 100644 --- a/docs/database.md +++ b/docs/database.md @@ -22,6 +22,14 @@ Mutations advance `current_revision_id` only when the caller supplies the current parent. Logical deletion advances the revision and retains the tombstone. Qualities preserve `TB`, `B`, `Moy`, `M`, and `TM` exactly. +`sleep_diary_publication_state` separates the durable local tip from the tip +explicitly validated for publication and the exact revision covered by a +full-generation ACK. `sleep_diary_generation_entries` binds captured revisions +to immutable generation identities. Consequently a new entry is a draft, a +validated tip is ready, an ACKed tip is synchronized, and any later local tip +is modified without rewriting its acknowledged ancestor. One stable entry is +owned by each `night_start_date`; old drafts remain editable indefinitely. + The absolute timestamps are the future heart-rate correlation boundary. Sleep Diary V1 does not store, infer, or simulate heart-rate data. diff --git a/docs/sync_exchange.md b/docs/sync_exchange.md index 376773d..37dd120 100644 --- a/docs/sync_exchange.md +++ b/docs/sync_exchange.md @@ -9,6 +9,16 @@ siblings and resurrection, and treats an exact revision replay as idempotent. This completion happened before publication or freezing of the companion; `TRAINLOG_FORMAT_V1` remains unchanged and frozen. +Every field edit commits locally as a new immutable diary revision but does +not itself enter a generation. Explicit day validation marks only the guarded +current tip publishable and starts no transport. Generation capture exports +validated current tips, records each exact generation/revision relation, and +only a correlated consumed ACK advances the acknowledged marker. A correction +after ACK therefore remains local and appears as modified until it is +revalidated and acknowledged by a later generation. Imported tips are already +validated and acknowledged because their containing generation was durably +consumed. Draft and synchronized state are not inferred from wall-clock time. + `trainlog-sleep-diary`, version 1, is emitted by full generations on the 0.1.4 Sleep Diary branch. It is optional when consuming an older generation so a pre-domain artifact remains replayable. It carries bounded complete entry snapshots with diff --git a/docs/tests.md b/docs/tests.md index 67bd862..55b1c68 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -4,6 +4,9 @@ Sleep Diary coverage includes schema creation/migration, midnight and DST offsets, revision conflict, logical deletion/reopen, companion replay and rollback, Web periods/deep links, Android persistence, and vector PDF construction/pagination for 7, 14, 21 and 30 synthetic days. +Lifecycle regressions additionally cover durable drafts excluded from export, +guarded validation, reopen, post-validation correction, exact +generation-to-revision capture and ACK-only acknowledgement. `web_analysis` covers empty history, every 7/30/90/all period selector, loaded and unloaded repetitions, continuous duration/distance/speed, explicit diff --git a/tests/test_sleep_diary_exchange.py b/tests/test_sleep_diary_exchange.py index d094a1d..eea8626 100644 --- a/tests/test_sleep_diary_exchange.py +++ b/tests/test_sleep_diary_exchange.py @@ -36,6 +36,7 @@ class SleepExchangeTest(unittest.TestCase): CREATE TABLE sleep_medications(medication_id TEXT PRIMARY KEY,created_at TEXT,updated_at TEXT,current_revision_id TEXT,deleted INTEGER); CREATE TABLE sleep_medication_revisions(revision_id TEXT PRIMARY KEY,medication_id TEXT,parent_revision_id TEXT,created_at TEXT,name TEXT,default_dose_value REAL,default_dose_unit TEXT,form TEXT,note TEXT,active INTEGER); CREATE TABLE sleep_medication_intakes(revision_id TEXT,intake_id TEXT,medication_id TEXT,medication_name TEXT,taken_at TEXT,dose_value REAL,dose_unit TEXT,note TEXT,created_at TEXT,PRIMARY KEY(revision_id,intake_id)); + CREATE TABLE sleep_diary_publication_state(entry_id TEXT PRIMARY KEY,validated_revision_id TEXT,validated_at TEXT,acknowledged_revision_id TEXT,acknowledged_at TEXT); """) def document(self, item): @@ -67,6 +68,20 @@ class SleepExchangeTest(unittest.TestCase): with self.assertRaisesRegex(ValueError, "positive"): exchange.validate(self.document(invalid)) + def test_draft_is_durable_but_not_exported_until_validated(self): + revision = "slr_20000000-0000-4000-8000-000000000002" + item = entry(revision) + self.db.execute("INSERT INTO sleep_diary_entries VALUES(?,?,?,?,?,?,?)", + (item["entry_id"], item["night_start_date"], item["night_end_date"], + item["created_at"], item["updated_at"], revision, 0)) + self.db.execute("INSERT INTO sleep_diary_revisions VALUES(?,?,?,?,?,?,?,?)", + (revision, item["entry_id"], None, item["updated_at"], "B", "Moy", "TB", + item["treatment_and_notes"])) + self.assertEqual([], exchange.build(self.db)["entries"]) + self.db.execute("INSERT INTO sleep_diary_publication_state VALUES(?,?,?,NULL,NULL)", + (item["entry_id"], revision, item["updated_at"])) + self.assertEqual(revision, exchange.build(self.db)["entries"][0]["revision_id"]) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_sync_generation_exchange.py b/tests/test_sync_generation_exchange.py index 5f1c6c1..6931ee2 100644 --- a/tests/test_sync_generation_exchange.py +++ b/tests/test_sync_generation_exchange.py @@ -71,6 +71,38 @@ class GenerationTest(unittest.TestCase): with closing(sqlite3.connect(destination)) as reopened: self.assertEqual(ack,generation.record_consumed(reopened,manifest,checksum));self.assertEqual(1,reopened.execute("SELECT count(*) FROM facts WHERE id='imported'").fetchone()[0]) + def test_sleep_revision_becomes_acknowledged_only_after_correlated_ack(self): + stage, manifest, checksum = self.capture() + self.publish() + entry = "sl_10000000-0000-4000-8000-000000000001" + revision = "slr_20000000-0000-4000-8000-000000000002" + with closing(sqlite3.connect(self.database)) as db: + db.executescript(""" + CREATE TABLE sleep_diary_publication_state(entry_id TEXT PRIMARY KEY,validated_revision_id TEXT,validated_at TEXT,acknowledged_revision_id TEXT,acknowledged_at TEXT); + CREATE TABLE sleep_diary_generation_entries(generation_id TEXT,entry_id TEXT,revision_id TEXT,PRIMARY KEY(generation_id,entry_id)); + CREATE TABLE program_deletions(generation_id TEXT,acknowledged_at TEXT); + PRAGMA user_version=29; + """) + db.execute("INSERT INTO sleep_diary_publication_state VALUES(?,?,?,NULL,NULL)", + (entry, revision, "2026-09-21T17:30:00+02:00")) + db.execute("INSERT INTO sleep_diary_generation_entries VALUES(?,?,?)", + (GEN, entry, revision)) + db.execute("UPDATE sync_generations SET status='waiting_acknowledgement' WHERE generation_id=?", (GEN,)) + db.commit() + ack = generation.ack_document( + manifest["run_id"], manifest["generation_id"], manifest["producer"]["peer_id"], + manifest["consumer_peer_id"], checksum, "consumed", + "2026-09-21T18:00:00+02:00", + ) + ack_path = self.root / "sleep-ack.json" + ack_path.write_bytes(generation.canonical(ack)) + self.assertEqual("acknowledged", generation.accept_ack(self.database, ack_path)) + with closing(sqlite3.connect(self.database)) as db: + self.assertEqual((revision,), db.execute( + "SELECT acknowledged_revision_id FROM sleep_diary_publication_state WHERE entry_id=?", + (entry,), + ).fetchone()) + def test_withdrawal_generation_relation_is_durable_until_ack(self): withdrawal = "spw_77777777-7777-4777-8777-777777777777" preparation = "sp_88888888-8888-4888-8888-888888888888" diff --git a/tools/sleep_diary_exchange.py b/tools/sleep_diary_exchange.py index 3283151..defcd86 100644 --- a/tools/sleep_diary_exchange.py +++ b/tools/sleep_diary_exchange.py @@ -170,9 +170,13 @@ def load(path: Path) -> dict: def build(db: sqlite3.Connection) -> dict: entries = [] + # CONTRACT: local durability does not publish a draft. A generation may + # capture only the current tip explicitly validated by the user. rows = db.execute( - "SELECT entry_id,night_start_date,night_end_date,created_at,updated_at,current_revision_id,deleted " - "FROM sleep_diary_entries ORDER BY entry_id" + "SELECT e.entry_id,e.night_start_date,e.night_end_date,e.created_at,e.updated_at," + "e.current_revision_id,e.deleted FROM sleep_diary_entries e JOIN " + "sleep_diary_publication_state p ON p.entry_id=e.entry_id AND " + "p.validated_revision_id=e.current_revision_id ORDER BY e.entry_id" ).fetchall() if len(rows) > MAX_ENTRIES: fail("sleep diary entry bound exceeded") @@ -241,6 +245,15 @@ def apply(db: sqlite3.Connection, root: dict) -> tuple[int, int]: ) if persisted != item: fail("sleep diary revision identity reused with different content") + db.execute( + "INSERT INTO sleep_diary_publication_state(entry_id,validated_revision_id,validated_at," + "acknowledged_revision_id,acknowledged_at) VALUES(?,?,?,?,?) ON CONFLICT(entry_id) " + "DO UPDATE SET validated_revision_id=excluded.validated_revision_id," + "validated_at=excluded.validated_at,acknowledged_revision_id=excluded.acknowledged_revision_id," + "acknowledged_at=excluded.acknowledged_at", + (item["entry_id"], item["revision_id"], root["generated_at"], + item["revision_id"], root["generated_at"]), + ) unchanged += 1 continue if local is not None and item["parent_revision_id"] != local[0]: @@ -269,6 +282,17 @@ def apply(db: sqlite3.Connection, root: dict) -> tuple[int, int]: intake["medication_name"], intake["taken_at"], intake["dose_value"], intake["dose_unit"], intake["note"], intake["created_at"]) for intake in item["intakes"]]) + # An imported revision has already crossed the synchronization + # boundary; recording both markers prevents it appearing as a draft. + db.execute( + "INSERT INTO sleep_diary_publication_state(entry_id,validated_revision_id,validated_at," + "acknowledged_revision_id,acknowledged_at) VALUES(?,?,?,?,?) ON CONFLICT(entry_id) " + "DO UPDATE SET validated_revision_id=excluded.validated_revision_id," + "validated_at=excluded.validated_at,acknowledged_revision_id=excluded.acknowledged_revision_id," + "acknowledged_at=excluded.acknowledged_at", + (item["entry_id"], item["revision_id"], root["generated_at"], + item["revision_id"], root["generated_at"]), + ) applied += 1 return applied, unchanged diff --git a/tools/sync_generation_exchange.py b/tools/sync_generation_exchange.py index d0ba2e1..53268b8 100644 --- a/tools/sync_generation_exchange.py +++ b/tools/sync_generation_exchange.py @@ -456,6 +456,15 @@ def capture_desktop(database: Path, owned_root: Path, consumer: str, ) if cursor.rowcount != 1: raise GenerationError("program deletion changed during capture") + sleep_path = stage / "sleep-diary-v1.json" + if sleep_path.is_file(): + captured_sleep = sleep_diary_exchange.load(sleep_path) + db.executemany( + "INSERT INTO sleep_diary_generation_entries(generation_id,entry_id,revision_id) " + "VALUES(?,?,?)", + [(generation, item["entry_id"], item["revision_id"]) + for item in captured_sleep["entries"]], + ) db.commit() return stage, manifest, checksum except Exception: @@ -730,6 +739,18 @@ def accept_ack(database: Path, path: Path) -> str: db.execute("UPDATE sync_generations SET status=?,acknowledged_at=? WHERE generation_id=?", (status, ack["consumed_at"], ack["generation_id"])) if status == "acknowledged": + # The relation was captured from the immutable staged artifact; + # ACK therefore advances exactly those revisions and never a + # later local edit. + if db.execute("PRAGMA user_version").fetchone()[0] >= 29: + db.execute( + "UPDATE sleep_diary_publication_state SET acknowledged_revision_id=(SELECT " + "m.revision_id FROM sleep_diary_generation_entries m WHERE " + "m.generation_id=? AND m.entry_id=sleep_diary_publication_state.entry_id)," + "acknowledged_at=? WHERE EXISTS(SELECT 1 FROM sleep_diary_generation_entries m " + "WHERE m.generation_id=? AND m.entry_id=sleep_diary_publication_state.entry_id)", + (ack["generation_id"], ack["consumed_at"], ack["generation_id"]), + ) db.execute( "UPDATE session_preparation_deliveries SET state='acknowledged',acknowledged_at=? " "WHERE generation_id=? AND state IN('pending','remote_unknown')", diff --git a/tui/include/trainlog/sleep_diary.h b/tui/include/trainlog/sleep_diary.h index ee7c3f5..e67c25c 100644 --- a/tui/include/trainlog/sleep_diary.h +++ b/tui/include/trainlog/sleep_diary.h @@ -68,6 +68,13 @@ typedef enum TrainlogSleepEventType { TRAINLOG_SLEEP_EVENT_DAYTIME_SLEEPINESS } TrainlogSleepEventType; +typedef enum TrainlogSleepPublicationStatus { + TRAINLOG_SLEEP_PUBLICATION_DRAFT = 0, + TRAINLOG_SLEEP_PUBLICATION_READY, + TRAINLOG_SLEEP_PUBLICATION_SYNCHRONIZED, + TRAINLOG_SLEEP_PUBLICATION_MODIFIED +} TrainlogSleepPublicationStatus; + typedef struct TrainlogSleepEvent { char event_id[TRAINLOG_SLEEP_EVENT_ID_CAPACITY]; TrainlogSleepEventType type; @@ -91,6 +98,9 @@ typedef struct TrainlogSleepDiaryEntry { size_t event_count; TrainlogMedicationIntake intakes[TRAINLOG_SLEEP_INTAKES_MAX]; size_t intake_count; + char validated_revision_id[TRAINLOG_SLEEP_REVISION_ID_CAPACITY]; + char acknowledged_revision_id[TRAINLOG_SLEEP_REVISION_ID_CAPACITY]; + TrainlogSleepPublicationStatus publication_status; bool deleted; } TrainlogSleepDiaryEntry; @@ -132,6 +142,13 @@ trainlog_sleep_diary_delete(TrainlogDatabase *database, const char *deleted_at, char output_revision[TRAINLOG_SLEEP_REVISION_ID_CAPACITY]); +/* Marks the guarded current revision as complete enough for the existing + * full-generation publisher. It remains editable and no transport is started. */ +TrainlogStatus trainlog_sleep_diary_validate_revision(TrainlogDatabase *database, + const char *entry_id, + const char *expected_revision, + const char *validated_at); + typedef TrainlogStatus (*TrainlogMedicationVisitor)(void *context, const TrainlogMedication *medication); diff --git a/tui/include/trainlog/web_sleep.h b/tui/include/trainlog/web_sleep.h index 01a8661..633a8fc 100644 --- a/tui/include/trainlog/web_sleep.h +++ b/tui/include/trainlog/web_sleep.h @@ -37,6 +37,12 @@ TrainlogStatus trainlog_web_sleep_delete_request_json(TrainlogDatabase *database char **output_json, size_t *output_size); +TrainlogStatus trainlog_web_sleep_validate_json(TrainlogDatabase *database, + const char *body, + size_t body_size, + char **output_json, + size_t *output_size); + TrainlogStatus trainlog_web_medication_list_json(TrainlogDatabase *database, char **output_json, size_t *output_size); diff --git a/tui/src/database.c b/tui/src/database.c index b528cf0..828a484 100644 --- a/tui/src/database.c +++ b/tui/src/database.c @@ -1330,6 +1330,17 @@ static const char *const MIGRATE_V28_TO_V29_SQL = "CREATE INDEX IF NOT EXISTS sleep_medications_active ON sleep_medications(deleted,updated_at);" "CREATE INDEX IF NOT EXISTS sleep_medication_intakes_time ON " "sleep_medication_intakes(taken_at,medication_id);" + "CREATE TABLE IF NOT EXISTS sleep_diary_publication_state(" + "entry_id TEXT PRIMARY KEY REFERENCES sleep_diary_entries(entry_id) ON DELETE RESTRICT," + "validated_revision_id TEXT,validated_at TEXT,acknowledged_revision_id TEXT," + "acknowledged_at TEXT," + "CHECK((validated_revision_id IS NULL)=(validated_at IS NULL))," + "CHECK((acknowledged_revision_id IS NULL)=(acknowledged_at IS NULL)));" + "CREATE TABLE IF NOT EXISTS sleep_diary_generation_entries(" + "generation_id TEXT NOT NULL REFERENCES sync_generations(generation_id) ON DELETE RESTRICT," + "entry_id TEXT NOT NULL REFERENCES sleep_diary_entries(entry_id) ON DELETE RESTRICT," + "revision_id TEXT NOT NULL REFERENCES sleep_diary_revisions(revision_id) ON DELETE RESTRICT," + "PRIMARY KEY(generation_id,entry_id));" "PRAGMA user_version=29;COMMIT;"; /* WHY: schema v29 was already opened on the private 0.1.4 review installation @@ -1361,7 +1372,20 @@ static const char *const ENSURE_V29_SLEEP_MEDICATION_SQL = "(dose_value>0 AND length(trim(dose_unit)) BETWEEN 1 AND 32)));" "CREATE INDEX IF NOT EXISTS sleep_medications_active ON sleep_medications(deleted,updated_at);" "CREATE INDEX IF NOT EXISTS sleep_medication_intakes_time ON " - "sleep_medication_intakes(taken_at,medication_id);COMMIT;"; + "sleep_medication_intakes(taken_at,medication_id);" + "CREATE UNIQUE INDEX IF NOT EXISTS sleep_diary_one_night ON " + "sleep_diary_entries(night_start_date);" + "CREATE TABLE IF NOT EXISTS sleep_diary_publication_state(" + "entry_id TEXT PRIMARY KEY REFERENCES sleep_diary_entries(entry_id) ON DELETE RESTRICT," + "validated_revision_id TEXT,validated_at TEXT,acknowledged_revision_id TEXT," + "acknowledged_at TEXT," + "CHECK((validated_revision_id IS NULL)=(validated_at IS NULL))," + "CHECK((acknowledged_revision_id IS NULL)=(acknowledged_at IS NULL)));" + "CREATE TABLE IF NOT EXISTS sleep_diary_generation_entries(" + "generation_id TEXT NOT NULL REFERENCES sync_generations(generation_id) ON DELETE RESTRICT," + "entry_id TEXT NOT NULL REFERENCES sleep_diary_entries(entry_id) ON DELETE RESTRICT," + "revision_id TEXT NOT NULL REFERENCES sleep_diary_revisions(revision_id) ON DELETE RESTRICT," + "PRIMARY KEY(generation_id,entry_id));COMMIT;"; /* CONTRACT: migration fixtures may retain additive v25 columns while * deliberately lowering user_version. Add each provenance column only when diff --git a/tui/src/sleep_diary.c b/tui/src/sleep_diary.c index 792669d..4780bdb 100644 --- a/tui/src/sleep_diary.c +++ b/tui/src/sleep_diary.c @@ -419,8 +419,10 @@ static TrainlogStatus load_entry(TrainlogDatabase *database, "SELECT " "e.entry_id,e.night_start_date,e.night_end_date,e.created_at,e.updated_at,e.current_" "revision_id,e.deleted,r.parent_revision_id,r.sleep_quality,r.wake_quality,r.day_form,r." - "treatment_and_notes FROM sleep_diary_entries e JOIN sleep_diary_revisions r ON " - "r.revision_id=e.current_revision_id WHERE e.entry_id=?1 AND (?2 OR e.deleted=0)"; + "treatment_and_notes,p.validated_revision_id,p.acknowledged_revision_id FROM " + "sleep_diary_entries e JOIN sleep_diary_revisions r ON " + "r.revision_id=e.current_revision_id LEFT JOIN sleep_diary_publication_state p ON " + "p.entry_id=e.entry_id WHERE e.entry_id=?1 AND (?2 OR e.deleted=0)"; static const char events_sql[] = "SELECT event_id,event_type,start_at,end_at FROM sleep_diary_events WHERE revision_id=?1 " "ORDER BY start_at COLLATE BINARY,event_id COLLATE BINARY"; @@ -472,6 +474,31 @@ static TrainlogStatus load_entry(TrainlogDatabase *database, sqlite3_column_text(statement, 11) == NULL ? "" : (const char *)sqlite3_column_text(statement, 11)); + (void)snprintf(output->validated_revision_id, + sizeof(output->validated_revision_id), + "%s", + sqlite3_column_text(statement, 12) == NULL + ? "" + : (const char *)sqlite3_column_text(statement, 12)); + (void)snprintf(output->acknowledged_revision_id, + sizeof(output->acknowledged_revision_id), + "%s", + sqlite3_column_text(statement, 13) == NULL + ? "" + : (const char *)sqlite3_column_text(statement, 13)); + /* INVARIANT: durable local edits are independent from publication. An ACK only + * describes the exact revision captured by a completed generation. */ + if (output->acknowledged_revision_id[0] != '\0' && + strcmp(output->acknowledged_revision_id, output->revision_id) == 0) { + output->publication_status = TRAINLOG_SLEEP_PUBLICATION_SYNCHRONIZED; + } else if (output->acknowledged_revision_id[0] != '\0') { + output->publication_status = TRAINLOG_SLEEP_PUBLICATION_MODIFIED; + } else if (output->validated_revision_id[0] != '\0' && + strcmp(output->validated_revision_id, output->revision_id) == 0) { + output->publication_status = TRAINLOG_SLEEP_PUBLICATION_READY; + } else { + output->publication_status = TRAINLOG_SLEEP_PUBLICATION_DRAFT; + } if (sqlite3_finalize(statement) != SQLITE_OK) { return TRAINLOG_STATUS_DATABASE_ERROR; } @@ -553,6 +580,47 @@ static TrainlogStatus load_entry(TrainlogDatabase *database, : TRAINLOG_STATUS_DATABASE_ERROR; } +TrainlogStatus trainlog_sleep_diary_validate_revision(TrainlogDatabase *database, + const char *entry_id, + const char *expected_revision, + const char *validated_at) { + static const char sql[] = + "INSERT INTO sleep_diary_publication_state(entry_id,validated_revision_id,validated_at) " + "SELECT entry_id,current_revision_id,?1 FROM sleep_diary_entries WHERE entry_id=?2 AND " + "current_revision_id=?3 AND deleted=0 ON CONFLICT(entry_id) DO UPDATE SET " + "validated_revision_id=excluded.validated_revision_id,validated_at=excluded.validated_at"; + sqlite3_stmt *statement = NULL; + TrainlogTimestampKey timestamp; + int result; + if (database == NULL || !id_valid(entry_id, "sl", TRAINLOG_SLEEP_ENTRY_ID_CAPACITY) || + !id_valid(expected_revision, "slr", TRAINLOG_SLEEP_REVISION_ID_CAPACITY) || + !timestamp_valid(validated_at, ×tamp)) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + result = sqlite3_prepare_v2(database->connection, sql, -1, &statement, NULL); + if (result == SQLITE_OK) { + result = sqlite3_bind_text(statement, 1, validated_at, -1, SQLITE_TRANSIENT); + } + if (result == SQLITE_OK) { + result = sqlite3_bind_text(statement, 2, entry_id, -1, SQLITE_TRANSIENT); + } + if (result == SQLITE_OK) { + result = sqlite3_bind_text(statement, 3, expected_revision, -1, SQLITE_TRANSIENT); + } + if (result == SQLITE_OK) { + result = sqlite3_step(statement) == SQLITE_DONE ? SQLITE_OK + : sqlite3_errcode(database->connection); + } + if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK && result == SQLITE_OK) { + result = SQLITE_ERROR; + } + if (result != SQLITE_OK) { + return TRAINLOG_STATUS_DATABASE_ERROR; + } + return sqlite3_changes(database->connection) == 1 ? TRAINLOG_STATUS_OK + : TRAINLOG_STATUS_CONFLICT; +} + TrainlogStatus trainlog_sleep_diary_get(TrainlogDatabase *database, const char *entry_id, bool include_deleted, @@ -626,6 +694,10 @@ trainlog_sleep_diary_delete(TrainlogDatabase *database, static const char sql[] = "UPDATE sleep_diary_entries SET deleted=1,updated_at=?1,current_revision_id=?2 WHERE " "entry_id=?3 AND current_revision_id=?4 AND deleted=0"; + static const char publication_sql[] = + "INSERT INTO sleep_diary_publication_state(entry_id,validated_revision_id,validated_at) " + "VALUES(?1,?2,?3) ON CONFLICT(entry_id) DO UPDATE SET " + "validated_revision_id=excluded.validated_revision_id,validated_at=excluded.validated_at"; sqlite3_stmt *statement = NULL; TrainlogSleepDiaryEntry entry; TrainlogStatus status; @@ -680,6 +752,31 @@ trainlog_sleep_diary_delete(TrainlogDatabase *database, : sqlite3_changes(database->connection) == 1 ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_CONFLICT; } + /* Deletion is itself an explicit publication decision. Capturing its + * immutable tombstone preserves causal non-resurrection on the peer. */ + if (status == TRAINLOG_STATUS_OK) { + statement = NULL; + result = sqlite3_prepare_v2(database->connection, publication_sql, -1, &statement, NULL); + if (result == SQLITE_OK) { + result = sqlite3_bind_text(statement, 1, entry_id, -1, SQLITE_TRANSIENT); + } + if (result == SQLITE_OK) { + result = sqlite3_bind_text(statement, 2, entry.revision_id, -1, SQLITE_TRANSIENT); + } + if (result == SQLITE_OK) { + result = sqlite3_bind_text(statement, 3, deleted_at, -1, SQLITE_TRANSIENT); + } + if (result == SQLITE_OK) { + result = sqlite3_step(statement) == SQLITE_DONE ? SQLITE_OK + : sqlite3_errcode(database->connection); + } + if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK && result == SQLITE_OK) { + result = SQLITE_ERROR; + } + if (result != SQLITE_OK) { + status = TRAINLOG_STATUS_DATABASE_ERROR; + } + } if (status == TRAINLOG_STATUS_OK && sqlite3_exec(database->connection, "COMMIT", NULL, NULL, NULL) == SQLITE_OK) { (void)snprintf( diff --git a/tui/src/web_server.c b/tui/src/web_server.c index 047786e..25bf8a1 100644 --- a/tui/src/web_server.c +++ b/tui/src/web_server.c @@ -1407,12 +1407,20 @@ static enum MHD_Result handle_request(void *closure, } return queue_json(connection, MHD_HTTP_OK, json, NULL); } - if (strcmp(url, "/api/v1/sleep-diary") == 0) { + if (strcmp(url, "/api/v1/sleep-diary") == 0 || + strcmp(url, "/api/v1/sleep-diary/validate") == 0) { char *json = NULL; size_t json_size = 0U; TrainlogStatus status; enum MHD_Result queued; - if (is_get) { + if (strcmp(url, "/api/v1/sleep-diary/validate") == 0 && + strcmp(method, MHD_HTTP_METHOD_POST) != 0) { + return queue_json(connection, + MHD_HTTP_METHOD_NOT_ALLOWED, + "{\"error\":\"method_not_allowed\"}\n", + "POST"); + } + if (is_get && strcmp(url, "/api/v1/sleep-diary") == 0) { const char *start = MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "start_date"); const char *end = @@ -1438,8 +1446,17 @@ static enum MHD_Result handle_request(void *closure, connection, MHD_HTTP_FORBIDDEN, "{\"error\":\"mutation_forbidden\"}\n", NULL); } status = strcmp(method, MHD_HTTP_METHOD_POST) == 0 - ? trainlog_web_sleep_save_json( - context->database, state->body, state->body_size, &json, &json_size) + ? (strcmp(url, "/api/v1/sleep-diary/validate") == 0 + ? trainlog_web_sleep_validate_json(context->database, + state->body, + state->body_size, + &json, + &json_size) + : trainlog_web_sleep_save_json(context->database, + state->body, + state->body_size, + &json, + &json_size)) : trainlog_web_sleep_delete_request_json( context->database, state->body, state->body_size, &json, &json_size); } else { diff --git a/tui/src/web_sleep.c b/tui/src/web_sleep.c index 3048873..8e01e2f 100644 --- a/tui/src/web_sleep.c +++ b/tui/src/web_sleep.c @@ -63,6 +63,14 @@ static const char *quality_text(TrainlogSleepQuality quality) { : NULL; } +static const char *publication_text(TrainlogSleepPublicationStatus status) { + static const char *const values[] = {"draft", "ready", "synchronized", "modified"}; + return status >= TRAINLOG_SLEEP_PUBLICATION_DRAFT && + status <= TRAINLOG_SLEEP_PUBLICATION_MODIFIED + ? values[status] + : NULL; +} + static bool quality_value(yyjson_val *value, TrainlogSleepQuality *output) { int index; if (yyjson_is_null(value)) { @@ -131,6 +139,8 @@ add_entry(yyjson_mut_doc *document, yyjson_mut_val *items, const TrainlogSleepDi !yyjson_mut_obj_add_strcpy(document, object, "created_at", entry->created_at) || !yyjson_mut_obj_add_strcpy(document, object, "updated_at", entry->updated_at) || !yyjson_mut_obj_add_strcpy(document, object, "revision_id", entry->revision_id) || + !yyjson_mut_obj_add_strcpy( + document, object, "publication_status", publication_text(entry->publication_status)) || !add_nullable_quality(document, object, "sleep_quality", entry->sleep_quality) || !add_nullable_quality(document, object, "wake_quality", entry->wake_quality) || !add_nullable_quality(document, object, "day_form", entry->day_form) || @@ -553,6 +563,62 @@ TrainlogStatus trainlog_web_sleep_delete_request_json(TrainlogDatabase *database return status; } +TrainlogStatus trainlog_web_sleep_validate_json(TrainlogDatabase *database, + const char *body, + size_t body_size, + char **output_json, + size_t *output_size) { + yyjson_read_err error; + yyjson_doc *input; + yyjson_val *root; + const char *entry_id; + const char *revision; + const char *validated_at; + char entry_id_copy[TRAINLOG_SLEEP_ENTRY_ID_CAPACITY]; + char revision_copy[TRAINLOG_SLEEP_REVISION_ID_CAPACITY]; + TrainlogStatus status; + yyjson_mut_doc *output; + yyjson_mut_val *object; + if (database == NULL || body == NULL || body_size == 0U || + body_size > TRAINLOG_WEB_SLEEP_BYTES_MAX || output_json == NULL || output_size == NULL) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + input = yyjson_read_opts((char *)body, body_size, YYJSON_READ_NOFLAG, NULL, &error); + root = input == NULL ? NULL : yyjson_doc_get_root(input); + entry_id = yyjson_is_obj(root) && yyjson_is_str(yyjson_obj_get(root, "entry_id")) + ? yyjson_get_str(yyjson_obj_get(root, "entry_id")) + : NULL; + revision = yyjson_is_obj(root) && yyjson_is_str(yyjson_obj_get(root, "expected_revision")) + ? yyjson_get_str(yyjson_obj_get(root, "expected_revision")) + : NULL; + validated_at = yyjson_is_obj(root) && yyjson_is_str(yyjson_obj_get(root, "validated_at")) + ? yyjson_get_str(yyjson_obj_get(root, "validated_at")) + : NULL; + if (entry_id == NULL || revision == NULL || validated_at == NULL || + yyjson_obj_size(root) != 3U) { + yyjson_doc_free(input); + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + status = trainlog_sleep_diary_validate_revision(database, entry_id, revision, validated_at); + (void)snprintf(entry_id_copy, sizeof(entry_id_copy), "%s", entry_id); + (void)snprintf(revision_copy, sizeof(revision_copy), "%s", revision); + yyjson_doc_free(input); + if (status != TRAINLOG_STATUS_OK) { + return status; + } + output = yyjson_mut_doc_new(NULL); + object = output == NULL ? NULL : yyjson_mut_obj(output); + if (output == NULL || object == NULL || + !yyjson_mut_obj_add_strcpy(output, object, "entry_id", entry_id_copy) || + !yyjson_mut_obj_add_strcpy(output, object, "revision_id", revision_copy) || + !yyjson_mut_obj_add_strcpy(output, object, "publication_status", "ready")) { + yyjson_mut_doc_free(output); + return TRAINLOG_STATUS_SYSTEM_ERROR; + } + yyjson_mut_doc_set_root(output, object); + return write_document(output, output_json, output_size); +} + typedef struct MedicationListContext { yyjson_mut_doc *document; yyjson_mut_val *items; diff --git a/tui/tests/test_schema_v29_migration.c b/tui/tests/test_schema_v29_migration.c index 7fdfdd2..678d018 100644 --- a/tui/tests/test_schema_v29_migration.c +++ b/tui/tests/test_schema_v29_migration.c @@ -50,7 +50,13 @@ int main(void) { production = NULL; CHECK(sqlite3_open(path, &raw) == SQLITE_OK); CHECK(scalar(raw, "PRAGMA user_version") == 29); - CHECK(scalar(raw, "SELECT COUNT(*) FROM sqlite_master WHERE name LIKE 'sleep_diary_%'") == 5); + CHECK(scalar(raw, + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN " + "('sleep_diary_entries','sleep_diary_revisions','sleep_diary_events'," + "'sleep_diary_publication_state','sleep_diary_generation_entries')") == 5); + CHECK(scalar(raw, + "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND " + "name='sleep_diary_one_night'") == 1); CHECK(scalar(raw, "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN " "('sleep_medications','sleep_medication_revisions'," diff --git a/tui/tests/test_sleep_diary.c b/tui/tests/test_sleep_diary.c index 0274d8f..4ab275b 100644 --- a/tui/tests/test_sleep_diary.c +++ b/tui/tests/test_sleep_diary.c @@ -136,7 +136,19 @@ int main(void) { CHECK(trainlog_sleep_diary_get(database, entry.entry_id, false, &loaded) == TRAINLOG_STATUS_OK); CHECK(loaded.event_count == 4U && loaded.intake_count == 2U && loaded.intakes[0].dose_value == 5.0 && loaded.intakes[1].dose_value == 10.0 && - loaded.sleep_quality == TRAINLOG_SLEEP_QUALITY_B); + loaded.sleep_quality == TRAINLOG_SLEEP_QUALITY_B && + loaded.publication_status == TRAINLOG_SLEEP_PUBLICATION_DRAFT); + CHECK(trainlog_sleep_diary_validate_revision( + database, entry.entry_id, loaded.revision_id, "2026-10-25T18:00:00+01:00") == + TRAINLOG_STATUS_OK); + CHECK(trainlog_sleep_diary_get(database, entry.entry_id, false, &loaded) == + TRAINLOG_STATUS_OK && + loaded.publication_status == TRAINLOG_SLEEP_PUBLICATION_READY); + CHECK(trainlog_sleep_diary_validate_revision(database, + entry.entry_id, + "slr_00000000-0000-4000-8000-000000000000", + "2026-10-25T18:00:00+01:00") == + TRAINLOG_STATUS_CONFLICT); (void)snprintf( medication.updated_at, sizeof(medication.updated_at), "2026-10-26T12:00:00+01:00"); (void)snprintf(medication.name, sizeof(medication.name), "Renamed medication"); @@ -157,6 +169,9 @@ int main(void) { loaded.day_form = TRAINLOG_SLEEP_QUALITY_TB; (void)snprintf(loaded.updated_at, sizeof(loaded.updated_at), "2026-10-25T19:00:00+01:00"); CHECK(trainlog_sleep_diary_update(database, first_revision, &loaded) == TRAINLOG_STATUS_OK); + CHECK(trainlog_sleep_diary_get(database, entry.entry_id, false, &loaded) == + TRAINLOG_STATUS_OK && + loaded.publication_status == TRAINLOG_SLEEP_PUBLICATION_DRAFT); stale.day_form = TRAINLOG_SLEEP_QUALITY_TM; (void)snprintf(stale.updated_at, sizeof(stale.updated_at), "2026-10-25T20:00:00+01:00"); CHECK(trainlog_sleep_diary_update(database, first_revision, &stale) == diff --git a/web/src/api/sleepDiary.ts b/web/src/api/sleepDiary.ts index bfd1509..599d0dc 100644 --- a/web/src/api/sleepDiary.ts +++ b/web/src/api/sleepDiary.ts @@ -14,6 +14,7 @@ export interface SleepEntry { created_at: string; updated_at: string; revision_id: string sleep_quality: SleepQuality | null; wake_quality: SleepQuality | null; day_form: SleepQuality | null treatment_and_notes: string; events: SleepEvent[]; intakes: MedicationIntake[] + publication_status: 'draft' | 'ready' | 'synchronized' | 'modified' } export interface SleepSnapshot { api_version: 1; entries: SleepEntry[] @@ -21,7 +22,9 @@ export interface SleepSnapshot { sleep_duration_seconds: number; long_awake_duration_seconds: number; nap_duration_seconds: number average_bed_minute: number | null; average_get_up_minute: number | null } } -export type SleepEntryInput = Omit & { expected_revision: string | null } +export type SleepEntryInput = Omit & { + expected_revision: string | null +} const object = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value) @@ -34,6 +37,7 @@ function validEntry(value: unknown): value is SleepEntry { return object(value) && typeof value.entry_id === 'string' && typeof value.night_start_date === 'string' && typeof value.night_end_date === 'string' && typeof value.created_at === 'string' && typeof value.updated_at === 'string' && typeof value.revision_id === 'string' && + ['draft', 'ready', 'synchronized', 'modified'].includes(String(value.publication_status)) && quality(value.sleep_quality) && quality(value.wake_quality) && quality(value.day_form) && typeof value.treatment_and_notes === 'string' && Array.isArray(value.events) && value.events.length <= 64 && value.events.every((event) => object(event) && typeof event.event_id === 'string' && @@ -74,6 +78,15 @@ async function mutation(method: 'POST' | 'DELETE', body: unknown): Promise<{ ent } export const saveSleepEntry = async (entry: SleepEntryInput) => await mutation('POST', entry) +export async function validateSleepEntry(entryId: string, revisionId: string): Promise { + const csrf = await mutationCsrfToken() + const response = await fetch('/api/v1/sleep-diary/validate', { + method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json', + 'X-Trainlog-CSRF-Token': csrf }, body: JSON.stringify({ entry_id: entryId, + expected_revision: revisionId, validated_at: new Date().toISOString() }), + }) + if (!response.ok) throw new Error('sleep_diary_validation_failed') +} export const deleteSleepEntry = async (entry: SleepEntry) => await mutation('DELETE', { entry_id: entry.entry_id, expected_revision: entry.revision_id, deleted_at: new Date().toISOString(), }) diff --git a/web/src/report/sleepDiaryPdf.test.ts b/web/src/report/sleepDiaryPdf.test.ts index 0ff8b93..49f7ee7 100644 --- a/web/src/report/sleepDiaryPdf.test.ts +++ b/web/src/report/sleepDiaryPdf.test.ts @@ -3,6 +3,7 @@ import { buildSleepDiaryPdf } from './sleepDiaryPdf' import type { SleepEntry, SleepSnapshot } from '../api/sleepDiary' const entry = (index: number): SleepEntry => ({ + publication_status: index === 0 ? 'draft' : 'synchronized', entry_id: `sl_${index}`, night_start_date: `2026-09-${String(index + 1).padStart(2, '0')}`, night_end_date: `2026-09-${String(index + 2).padStart(2, '0')}`, created_at: '2026-09-01T18:00:00+02:00', updated_at: '2026-09-01T18:00:00+02:00', revision_id: `slr_${index}`, sleep_quality: 'B', diff --git a/web/src/report/sleepDiaryPdf.ts b/web/src/report/sleepDiaryPdf.ts index 2bd0130..b48b2e0 100644 --- a/web/src/report/sleepDiaryPdf.ts +++ b/web/src/report/sleepDiaryPdf.ts @@ -39,7 +39,11 @@ function page(snapshot: SleepSnapshot, entries: SleepEntry[], index: number, cou entries.forEach((entry, rowIndex) => row(stream, entry, 538 - rowIndex * 40)) const y = 520 - entries.length * 40 stream.push(text(30, y, 9, 'OBSERVATIONS'), text(30, y - 14, 6, `${snapshot.summary.nights} nights; sleep ${Math.round(snapshot.summary.sleep_duration_seconds / 60)} min; long awake ${snapshot.summary.long_awake_count}; naps ${snapshot.summary.nap_count}; sleepiness ${snapshot.summary.sleepiness_count}; intakes ${snapshot.summary.intake_count}`)) - entries.filter((entry) => entry.treatment_and_notes).slice(0, 3).forEach((entry, noteIndex) => stream.push(text(30, y - 27 - noteIndex * 10, 6, `${entry.night_start_date}: ${entry.treatment_and_notes.slice(0, 110)}`))) + const unvalidated = snapshot.entries.filter((entry) => entry.publication_status === 'draft' || entry.publication_status === 'modified').length + if (unvalidated > 0) stream.push(text(30, y - 24, 6, language === 'fr' + ? `AVERTISSEMENT: ${unvalidated} jour(s) non valide(s) dans cet export.` + : `WARNING: ${unvalidated} unvalidated day(s) in this export.`)) + entries.filter((entry) => entry.treatment_and_notes).slice(0, 3).forEach((entry, noteIndex) => stream.push(text(30, y - 36 - noteIndex * 10, 6, `${entry.night_start_date}: ${entry.treatment_and_notes.slice(0, 110)}`))) stream.push(text(30, 18, 6, 'Legend: v bedtime; ^ get-up; S sleepiness; M medication intake; SLEEP sleep; NAP nap; AWAKE long awakening; HALF half-sleep.')) return stream.join('') } diff --git a/web/src/routes/SleepDiaryWorkspace.tsx b/web/src/routes/SleepDiaryWorkspace.tsx index 3e773be..852d7b9 100644 --- a/web/src/routes/SleepDiaryWorkspace.tsx +++ b/web/src/routes/SleepDiaryWorkspace.tsx @@ -1,5 +1,5 @@ -import { useEffect, useMemo, useState } from 'react' -import { deleteSleepEntry, fetchSleepDiary, fetchSleepMedications, saveSleepEntry, saveSleepMedication, +import { useEffect, useMemo, useRef, useState } from 'react' +import { deleteSleepEntry, fetchSleepDiary, fetchSleepMedications, saveSleepEntry, saveSleepMedication, validateSleepEntry, type MedicationIntake, type SleepEntry, type SleepEntryInput, type SleepEvent, type SleepEventType, type SleepMedication, type SleepQuality } from '../api/sleepDiary' import type { AnalysisPeriod } from '../api/analysis' @@ -16,7 +16,9 @@ const copy = { save: 'Enregistrer', remove: 'Supprimer', observations: 'Observations', summary: 'Synthèse factuelle', empty: 'Aucune nuit enregistrée.', preview: 'Prévisualiser', export: 'Exporter PDF', medications: 'Médicaments', addMedication: 'Ajouter un médicament', medication: 'Médicament', usualDose: 'Dosage habituel', unit: 'Unité', deactivate: 'Désactiver', intake: 'Ajouter une prise', - time: 'Heure', dose: 'Dosage', modify: 'Modifier', qualities: ['Très bon', 'Bon', 'Moyen', 'Mauvais', 'Très mauvais'] }, + time: 'Heure', dose: 'Dosage', modify: 'Modifier', validate: 'Valider la journée', continue: 'Continuer', + statuses: { draft: 'Brouillon', ready: 'Prête à synchroniser', synchronized: 'Synchronisée', modified: 'Modifiée depuis la synchronisation' }, + qualities: ['Très bon', 'Bon', 'Moyen', 'Mauvais', 'Très mauvais'] }, en: { agenda: 'Diary', edit: 'Entry editor', morning: 'Morning', day: 'Day / Evening', night: 'Night from', bed_time: 'Bedtime', final_get_up: 'Final get-up', night_get_up: 'Night get-up', sleep: 'Sleep', nap: 'Nap', long_awake: 'Long awakening', half_sleep: 'Half-sleep', daytime_sleepiness: 'Sleepiness', sleepQuality: 'Sleep quality', @@ -24,6 +26,8 @@ const copy = { observations: 'Observations', summary: 'Factual summary', empty: 'No recorded nights.', preview: 'Preview', export: 'Export PDF', medications: 'Medications', addMedication: 'Add a medication', medication: 'Medication', usualDose: 'Usual dose', unit: 'Unit', deactivate: 'Deactivate', intake: 'Add an intake', time: 'Time', dose: 'Dose', modify: 'Edit', + validate: 'Validate day', continue: 'Continue', statuses: { draft: 'Draft', ready: 'Ready to sync', + synchronized: 'Synchronized', modified: 'Modified since synchronization' }, qualities: ['Very good', 'Good', 'Average', 'Bad', 'Very bad'] }, } as const @@ -57,23 +61,37 @@ export function SleepDiaryWorkspace({ period, language }: { period: AnalysisPeri const [editingMedication, setEditingMedication] = useState(null) const [intakeMedication, setIntakeMedication] = useState(''); const [intakeTime, setIntakeTime] = useState('') const [intakeDose, setIntakeDose] = useState(''); const [intakeUnit, setIntakeUnit] = useState('mg') + const identity = useRef<{ entry_id: string; revision_id: string } | null>(null) + const persistence = useRef>(Promise.resolve()) const range = useMemo(() => { if (period === 'all') return {}; const days = Number(period.slice(0, -1)); const end = new Date(); const start = new Date(); start.setDate(end.getDate() - days + 1) return { startDate: start.toLocaleDateString('en-CA'), endDate: end.toLocaleDateString('en-CA') } }, [period]) const reload = () => Promise.all([fetchSleepDiary(range.startDate, range.endDate), fetchSleepMedications()]) .then(([diary, catalog]) => { setSnapshot(diary); setMedications(catalog) }).catch(() => setError('sleep_diary_unavailable')) useEffect(() => { void reload() }, [period]) - const edit = (entry: SleepEntry) => setDraft({ ...entry, expected_revision: entry.revision_id, - events: entry.events.map((event) => ({ ...event })), intakes: entry.intakes.map((intake) => ({ ...intake })) }) - const change = (key: K, value: SleepEntryInput[K]) => setDraft((current) => ({ ...current, [key]: value })) - const addEvent = () => setDraft((current) => ({ ...current, events: [...current.events, { event_id: '', type: eventType, + const edit = (entry: SleepEntry) => { identity.current = { entry_id: entry.entry_id, revision_id: entry.revision_id } + setDraft({ ...entry, expected_revision: entry.revision_id, + events: entry.events.map((event) => ({ ...event })), intakes: entry.intakes.map((intake) => ({ ...intake })) }) } + const persist = (next: SleepEntryInput) => { persistence.current = persistence.current.then(async () => { + const current = identity.current; const result = await saveSleepEntry({ ...next, + entry_id: current?.entry_id ?? next.entry_id, expected_revision: current?.revision_id ?? next.expected_revision, + updated_at: new Date().toISOString() }) + identity.current = result + setDraft((value) => ({ ...value, entry_id: result.entry_id, expected_revision: result.revision_id })) + }).catch((reason: unknown) => setError(reason instanceof Error ? reason.message : 'sleep_diary_mutation_failed')) } + const mutate = (transform: (current: SleepEntryInput) => SleepEntryInput) => setDraft((current) => { + const next = transform(current); persist(next); return next + }) + const change = (key: K, value: SleepEntryInput[K]) => + mutate((current) => ({ ...current, [key]: value })) + const addEvent = () => mutate((current) => ({ ...current, events: [...current.events, { event_id: `sle_${crypto.randomUUID()}`, type: eventType, start_at: new Date().toISOString(), end_at: pointTypes.includes(eventType) ? null : new Date(Date.now() + 3_600_000).toISOString() }] })) - const changeEvent = (index: number, patch: Partial) => setDraft((current) => ({ ...current, + const changeEvent = (index: number, patch: Partial) => mutate((current) => ({ ...current, events: current.events.map((event, currentIndex) => currentIndex === index ? { ...event, ...patch } : event) })) const selectMedication = (id: string) => { setIntakeMedication(id); const item = medications.find((med) => med.medication_id === id) setIntakeDose(item?.default_dose_value?.toString() ?? ''); setIntakeUnit(item?.default_dose_unit ?? 'mg') } const addIntake = () => { const medication = medications.find((item) => item.medication_id === intakeMedication); if (!medication || !intakeTime) return const dose = intakeDose === '' ? null : Number(intakeDose); if (dose !== null && !(dose > 0)) return - const intake: MedicationIntake = { intake_id: '', medication_id: medication.medication_id, + const intake: MedicationIntake = { intake_id: `mdi_${crypto.randomUUID()}`, medication_id: medication.medication_id, medication_name: medication.name, taken_at: absolute(intakeTime), dose_value: dose, dose_unit: dose === null ? null : intakeUnit, note: '', created_at: new Date().toISOString() } change('intakes', [...draft.intakes, intake].sort((a, b) => a.taken_at.localeCompare(b.taken_at))) } @@ -84,13 +102,16 @@ export function SleepDiaryWorkspace({ period, language }: { period: AnalysisPeri default_dose_unit: medDose === '' ? null : medUnit, form: editingMedication?.form ?? '', note: editingMedication?.note ?? '', active: editingMedication?.active ?? true }); setMedName(''); setEditingMedication(null); await reload() } - const save = async () => { setPending(true); setError(''); try { await saveSleepEntry({ ...draft, updated_at: new Date().toISOString() }); setDraft(blank()); await reload() } catch (reason) { setError(reason instanceof Error ? reason.message : 'sleep_diary_mutation_failed') } finally { setPending(false) } } + const save = async () => { setPending(true); setError(''); persist(draft); await persistence.current; await reload(); setPending(false) } + const validate = async () => { setPending(true); setError(''); persist(draft); await persistence.current + const current = identity.current; if (current) await validateSleepEntry(current.entry_id, current.revision_id) + await reload(); setPending(false) } return

{t.agenda}

{entries.length === 0 ?

{t.empty}

:
{entries.map((entry) =>
    {draft.intakes.map((intake, index) =>
  • {intake.medication_name}
  • )}