feat(sleep): add daily draft publication lifecycle

This commit is contained in:
fy59 2026-09-21 07:30:55 +02:00
parent afd1fcc92f
commit 0cf53228da
28 changed files with 603 additions and 43 deletions

View file

@ -10,6 +10,10 @@
medication catalog and timestamped intake snapshots with optional positive medication catalog and timestamped intake snapshots with optional positive
dose/unit, synchronized and rendered factually in the agenda and PDF without dose/unit, synchronized and rendered factually in the agenda and PDF without
efficacy, interaction, causality, dosage, or clinical advice. 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. Development opened after stable v0.1.3 release.

View file

@ -412,6 +412,16 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
arrayOf(operationId, generationId, if (existed) 0 else 1), 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) return CapturedSyncGeneration(generationId, runId, digest, stage)
} catch (error: Exception) { } catch (error: Exception) {
@ -1039,6 +1049,17 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
ack.getString("generation_id"), 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 target
} }
} }

View file

@ -45,6 +45,7 @@ import com.labfytools.trainlog.model.SyncedProgramSummary
import com.labfytools.trainlog.model.SleepDiaryDraft import com.labfytools.trainlog.model.SleepDiaryDraft
import com.labfytools.trainlog.model.SleepDiaryEntry import com.labfytools.trainlog.model.SleepDiaryEntry
import com.labfytools.trainlog.model.SleepDiaryEvent import com.labfytools.trainlog.model.SleepDiaryEvent
import com.labfytools.trainlog.model.SleepPublicationStatus
import com.labfytools.trainlog.model.MedicationIntake import com.labfytools.trainlog.model.MedicationIntake
import com.labfytools.trainlog.model.SleepMedication import com.labfytools.trainlog.model.SleepMedication
import com.labfytools.trainlog.model.SleepEventType import com.labfytools.trainlog.model.SleepEventType
@ -563,7 +564,11 @@ class TrainlogRepository(
fun listSleepDiary(limit: Int = 90): List<SleepDiaryEntry> { fun listSleepDiary(limit: Int = 90): List<SleepDiaryEntry> {
require(limit in 1..3660) require(limit in 1..3660)
val db = database.readableDatabase 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 { buildList {
while (cursor.moveToNext()) { while (cursor.moveToNext()) {
val revision = cursor.getString(5) 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 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))) } } 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 } } 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<SleepMedication> { fun listSleepMedications(includeInactive: Boolean = true): List<SleepMedication> {
val db = database.readableDatabase val db = database.readableDatabase
return db.rawQuery( return db.rawQuery(
@ -670,6 +704,12 @@ class TrainlogRepository(
guarded.bindString(3, entryId) guarded.bindString(3, entryId)
guarded.bindString(4, expectedRevision) guarded.bindString(4, expectedRevision)
if (guarded.executeUpdateDelete() != 1) return SaveSleepDiaryResult.Conflict 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() db.setTransactionSuccessful()
SaveSleepDiaryResult.Saved(entryId, revisionId) SaveSleepDiaryResult.Saved(entryId, revisionId)
} catch (_: android.database.sqlite.SQLiteException) { } catch (_: android.database.sqlite.SQLiteException) {
@ -684,8 +724,10 @@ class TrainlogRepository(
val entries = JSONArray() val entries = JSONArray()
val db = database.readableDatabase val db = database.readableDatabase
db.rawQuery( db.rawQuery(
"SELECT entry_id,night_start_date,night_end_date,created_at,updated_at,current_revision_id,deleted " + "SELECT e.entry_id,e.night_start_date,e.night_end_date,e.created_at,e.updated_at," +
"FROM sleep_diary_entries ORDER BY entry_id", "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, null,
).use { cursor -> ).use { cursor ->
while (cursor.moveToNext()) { while (cursor.moveToNext()) {
@ -881,6 +923,7 @@ class TrainlogRepository(
if (!sameEvents) return SleepDiaryImportResult.Rejected( if (!sameEvents) return SleepDiaryImportResult.Rejected(
"sleep diary revision identity reused with different content", "sleep diary revision identity reused with different content",
) )
markSleepDiaryImported(db, entryId, revisionId, root.getString("generated_at"))
unchanged++ unchanged++
continue continue
} }
@ -936,6 +979,7 @@ class TrainlogRepository(
intake.medicationName, intake.takenAt, intake.doseValue, intake.doseUnit, intake.medicationName, intake.takenAt, intake.doseValue, intake.doseUnit,
intake.note, intake.createdAt), intake.note, intake.createdAt),
) } ) }
markSleepDiaryImported(db, entryId, revisionId, root.getString("generated_at"))
advanced++ advanced++
} }
SleepDiaryImportResult.Applied(advanced, unchanged) 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. */ /** Create a transactionally consistent SQLite snapshot without exposing WAL files. */
internal fun backupDatabaseTo(destination: File) { internal fun backupDatabaseTo(destination: File) {
require(!destination.exists()) { "Backup snapshot destination already exists." } require(!destination.exists()) { "Backup snapshot destination already exists." }
@ -9602,6 +9662,7 @@ private class TrainlogDatabaseHelper(
)""".trimIndent(), )""".trimIndent(),
) )
db.execSQL("CREATE INDEX IF NOT EXISTS sleep_diary_entries_dates ON sleep_diary_entries(night_start_date,deleted)") 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 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( db.execSQL("""CREATE TABLE IF NOT EXISTS sleep_medications(
medication_id TEXT PRIMARY KEY, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, 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()) (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_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 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) { private fun createSyncDataLifecycleTables(db: SQLiteDatabase) {

View file

@ -6,6 +6,7 @@ enum class SleepEventType(val wireValue: String) {
SLEEP("sleep"), NAP("nap"), LONG_AWAKE("long_awake"), HALF_SLEEP("half_sleep"), SLEEP("sleep"), NAP("nap"), LONG_AWAKE("long_awake"), HALF_SLEEP("half_sleep"),
DAYTIME_SLEEPINESS("daytime_sleepiness"), 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 SleepDiaryEvent(val eventId: String, val type: SleepEventType, val startAt: String, val endAt: String?)
data class SleepMedication( data class SleepMedication(
val medicationId: String, val revisionId: String, val createdAt: String, val updatedAt: String, 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 createdAt: String, val updatedAt: String, val revisionId: String,
val sleepQuality: SleepQuality?, val wakeQuality: SleepQuality?, val dayForm: SleepQuality?, val sleepQuality: SleepQuality?, val wakeQuality: SleepQuality?, val dayForm: SleepQuality?,
val treatmentAndNotes: String, val events: List<SleepDiaryEvent>, val intakes: List<MedicationIntake>, val treatmentAndNotes: String, val events: List<SleepDiaryEvent>, val intakes: List<MedicationIntake>,
val publicationStatus: SleepPublicationStatus,
) )
data class SleepDiaryDraft( data class SleepDiaryDraft(
val entryId: String? = null, val expectedRevision: String? = null, val entryId: String? = null, val expectedRevision: String? = null,

View file

@ -14,6 +14,7 @@ import androidx.compose.material3.FilterChip
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember 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.SleepDiaryEvent
import com.labfytools.trainlog.model.SleepEventType import com.labfytools.trainlog.model.SleepEventType
import com.labfytools.trainlog.model.SleepQuality import com.labfytools.trainlog.model.SleepQuality
import com.labfytools.trainlog.model.SleepPublicationStatus
import com.labfytools.trainlog.model.MedicationIntake import com.labfytools.trainlog.model.MedicationIntake
import com.labfytools.trainlog.model.SleepMedication import com.labfytools.trainlog.model.SleepMedication
import java.time.LocalDate import java.time.LocalDate
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.util.UUID
@Composable @Composable
fun SleepDiaryScreen(repository: TrainlogRepository) { fun SleepDiaryScreen(repository: TrainlogRepository) {
@ -56,7 +59,22 @@ fun SleepDiaryScreen(repository: TrainlogRepository) {
var medicationDose by remember { mutableStateOf("") } var medicationDose by remember { mutableStateOf("") }
var medicationUnit by remember { mutableStateOf("mg") } var medicationUnit by remember { mutableStateOf("mg") }
var editingMedication by remember { mutableStateOf<SleepMedication?>(null) } var editingMedication by remember { mutableStateOf<SleepMedication?>(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( LazyColumn(
modifier = Modifier.fillMaxSize().padding(12.dp), modifier = Modifier.fillMaxSize().padding(12.dp),
@ -67,6 +85,12 @@ fun SleepDiaryScreen(repository: TrainlogRepository) {
Card(onClick = { selected = entry }) { Card(onClick = { selected = entry }) {
Column(Modifier.fillMaxWidth().padding(12.dp)) { Column(Modifier.fillMaxWidth().padding(12.dp)) {
Text("${entry.nightStartDate} → ${entry.nightEndDate}") 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 ?: "—"}") 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)) { Column(Modifier.fillMaxWidth().padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(stringResource(R.string.sleep_morning)) Text(stringResource(R.string.sleep_morning))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(startDate, { startDate = 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 }, 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_quality), sleepQuality) { sleepQuality = it; changed() }
QualityRow(stringResource(R.string.sleep_wake_quality), wakeQuality) { wakeQuality = it } QualityRow(stringResource(R.string.sleep_wake_quality), wakeQuality) { wakeQuality = it; changed() }
Text(stringResource(R.string.sleep_day)) 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 -> SleepEventType.entries.forEach { type ->
Button(onClick = { Button(onClick = {
val start = OffsetDateTime.now() 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()) if (type in pointTypes) null else start.plusHours(1).toString())
changed()
}) { Text(eventLabel(type)) } }) { Text(eventLabel(type)) }
} }
events.forEachIndexed { index, event -> events.forEachIndexed { index, event ->
@ -98,17 +123,19 @@ fun SleepDiaryScreen(repository: TrainlogRepository) {
events = events.mapIndexed { item, current -> events = events.mapIndexed { item, current ->
if (item == index) current.copy(startAt = value) else 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 -> event.endAt?.let { end ->
TimeButton(end) { value -> TimeButton(end) { value ->
events = events.mapIndexed { item, current -> events = events.mapIndexed { item, current ->
if (item == index) current.copy(endAt = value) else 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() val dose = intakeDose.toDoubleOrNull()
if (intakeDose.isEmpty() || dose != null && dose > 0.0) { if (intakeDose.isEmpty() || dose != null && dose > 0.0) {
val now = OffsetDateTime.now().toString() 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, medication.name, now, dose, if (dose == null) null else intakeUnit,
"", now) "", now)
changed()
} }
} }
}) { Text(stringResource(R.string.sleep_add_intake)) } }) { 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)) { Column(Modifier.fillMaxWidth().padding(8.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(intake.medicationName) Text(intake.medicationName)
TimeButton(intake.takenAt) { value -> intakes = intakes.mapIndexed { item, current -> 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)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(intake.doseValue?.toString().orEmpty(), { value -> OutlinedTextField(intake.doseValue?.toString().orEmpty(), { value ->
intakes = intakes.mapIndexed { item, current -> if (item == index) current.copy( intakes = intakes.mapIndexed { item, current -> if (item == index) current.copy(
doseValue = value.toDoubleOrNull(), doseUnit = if (value.isEmpty()) null else current.doseUnit ?: "mg") else current } 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)) }, label = { Text(stringResource(R.string.sleep_dose)) }, modifier = Modifier.weight(1f))
OutlinedTextField(intake.doseUnit.orEmpty(), { value -> intakes = intakes.mapIndexed { item, current -> OutlinedTextField(intake.doseUnit.orEmpty(), { value -> intakes = intakes.mapIndexed { item, current ->
if (item == index) current.copy(doseUnit = value) else current } if (item == index) current.copy(doseUnit = value) else current }
changed()
}, label = { Text(stringResource(R.string.sleep_unit)) }, modifier = Modifier.weight(1f)) }, 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)) 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 = { Button(onClick = {
val now = OffsetDateTime.now().toString() val now = OffsetDateTime.now().toString()
val current = selected val current = selected
val result = repository.saveSleepDiary(SleepDiaryDraft(current?.entryId,current?.revisionId,startDate,endDate,current?.createdAt ?: now,now,sleepQuality,wakeQuality,dayForm,notes,events,intakes)) 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 } } if (result is TrainlogRepository.SaveSleepDiaryResult.Saved) { revision++; selected = repository.listSleepDiary().firstOrNull { it.entryId == result.entryId } }
}) { Text(stringResource(R.string.sleep_save)) } }) { 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)) } } selected?.let { entry -> Button(onClick = { repository.deleteSleepDiary(entry.entryId,entry.revisionId,OffsetDateTime.now().toString()); selected = null; revision++ }) { Text(stringResource(R.string.sleep_delete)) } }
} }
} }

View file

@ -26,6 +26,11 @@
<string name="sleep_notes">Treatment and notes</string> <string name="sleep_notes">Treatment and notes</string>
<string name="sleep_add_event">Add event</string> <string name="sleep_add_event">Add event</string>
<string name="sleep_save">Save</string> <string name="sleep_save">Save</string>
<string name="sleep_validate">Validate day</string>
<string name="sleep_status_draft">Draft</string>
<string name="sleep_status_ready">Ready to sync</string>
<string name="sleep_status_synchronized">Synchronized</string>
<string name="sleep_status_modified">Modified since synchronization</string>
<string name="sleep_delete">Delete</string> <string name="sleep_delete">Delete</string>
<string name="sleep_choose_time">Choose time</string> <string name="sleep_choose_time">Choose time</string>
<string name="route_session_editor">Current session</string> <string name="route_session_editor">Current session</string>

View file

@ -26,6 +26,11 @@
<string name="sleep_notes">Traitement et remarques</string> <string name="sleep_notes">Traitement et remarques</string>
<string name="sleep_add_event">Ajouter un événement</string> <string name="sleep_add_event">Ajouter un événement</string>
<string name="sleep_save">Enregistrer</string> <string name="sleep_save">Enregistrer</string>
<string name="sleep_validate">Valider la journée</string>
<string name="sleep_status_draft">Brouillon</string>
<string name="sleep_status_ready">Prête à synchroniser</string>
<string name="sleep_status_synchronized">Synchronisée</string>
<string name="sleep_status_modified">Modifiée depuis la synchronisation</string>
<string name="sleep_delete">Supprimer</string> <string name="sleep_delete">Supprimer</string>
<string name="sleep_choose_time">Choisir l’heure</string> <string name="sleep_choose_time">Choisir l’heure</string>
<string name="route_session_editor">Séance en cours</string> <string name="route_session_editor">Séance en cours</string>

View file

@ -6,6 +6,7 @@ import com.labfytools.trainlog.model.SleepDiaryDraft
import com.labfytools.trainlog.model.SleepDiaryEvent import com.labfytools.trainlog.model.SleepDiaryEvent
import com.labfytools.trainlog.model.SleepEventType import com.labfytools.trainlog.model.SleepEventType
import com.labfytools.trainlog.model.SleepQuality import com.labfytools.trainlog.model.SleepQuality
import com.labfytools.trainlog.model.SleepPublicationStatus
import com.labfytools.trainlog.model.MedicationIntake import com.labfytools.trainlog.model.MedicationIntake
import com.labfytools.trainlog.model.SleepMedication import com.labfytools.trainlog.model.SleepMedication
import java.util.UUID import java.util.UUID
@ -39,6 +40,15 @@ class SleepDiaryRepositoryTest {
"Synthetic medication", "2026-10-25T05:15:00+01:00", 10.0, "mg", "", catalogTime))) "Synthetic medication", "2026-10-25T05:15:00+01:00", 10.0, "mg", "", catalogTime)))
val created = repository.saveSleepDiary(initial) as TrainlogRepository.SaveSleepDiaryResult.Saved val created = repository.saveSleepDiary(initial) as TrainlogRepository.SaveSleepDiaryResult.Saved
val loaded = repository.listSleepDiary().single() 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(2, loaded.events.size)
assertEquals(10.0, loaded.intakes.single().doseValue!!, 0.0) assertEquals(10.0, loaded.intakes.single().doseValue!!, 0.0)
val medication = repository.listSleepMedications().single() val medication = repository.listSleepMedications().single()
@ -56,6 +66,7 @@ class SleepDiaryRepositoryTest {
repository.close(); repository = TrainlogRepository(context, name) repository.close(); repository = TrainlogRepository(context, name)
val reopened = repository.listSleepDiary().single() val reopened = repository.listSleepDiary().single()
assertEquals(SleepQuality.MOY, reopened.wakeQuality) assertEquals(SleepQuality.MOY, reopened.wakeQuality)
assertEquals(SleepPublicationStatus.DRAFT, reopened.publicationStatus)
assertTrue(repository.deleteSleepDiary(reopened.entryId, reopened.revisionId, assertTrue(repository.deleteSleepDiary(reopened.entryId, reopened.revisionId,
"2026-10-26T12:00:00+01:00") is TrainlogRepository.SaveSleepDiaryResult.Saved) "2026-10-26T12:00:00+01:00") is TrainlogRepository.SaveSleepDiaryResult.Saved)
assertTrue(repository.listSleepDiary().isEmpty()) assertTrue(repository.listSleepDiary().isEmpty())

View file

@ -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, durable night/day entry without reproducing the desktop/PDF grid. Creation,
correction and logical deletion are revision guarded and survive repository correction and logical deletion are revision guarded and survive repository
reopen. Full-generation exchange transports the companion, never SQLite. 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 ## Background USB and private Drive synchronization

View file

@ -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 current parent. Logical deletion advances the revision and retains the
tombstone. Qualities preserve `TB`, `B`, `Moy`, `M`, and `TM` exactly. 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 The absolute timestamps are the future heart-rate correlation boundary. Sleep
Diary V1 does not store, infer, or simulate heart-rate data. Diary V1 does not store, infer, or simulate heart-rate data.

View file

@ -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; This completion happened before publication or freezing of the companion;
`TRAINLOG_FORMAT_V1` remains unchanged and frozen. `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 `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 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 pre-domain artifact remains replayable. It carries bounded complete entry snapshots with

View file

@ -4,6 +4,9 @@ Sleep Diary coverage includes schema creation/migration, midnight and DST
offsets, revision conflict, logical deletion/reopen, companion replay and offsets, revision conflict, logical deletion/reopen, companion replay and
rollback, Web periods/deep links, Android persistence, and vector PDF rollback, Web periods/deep links, Android persistence, and vector PDF
construction/pagination for 7, 14, 21 and 30 synthetic days. 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, `web_analysis` covers empty history, every 7/30/90/all period selector,
loaded and unloaded repetitions, continuous duration/distance/speed, explicit loaded and unloaded repetitions, continuous duration/distance/speed, explicit

View file

@ -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_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_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_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): def document(self, item):
@ -67,6 +68,20 @@ class SleepExchangeTest(unittest.TestCase):
with self.assertRaisesRegex(ValueError, "positive"): with self.assertRaisesRegex(ValueError, "positive"):
exchange.validate(self.document(invalid)) 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__": if __name__ == "__main__":
unittest.main() unittest.main()

View file

@ -71,6 +71,38 @@ class GenerationTest(unittest.TestCase):
with closing(sqlite3.connect(destination)) as reopened: 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]) 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): def test_withdrawal_generation_relation_is_durable_until_ack(self):
withdrawal = "spw_77777777-7777-4777-8777-777777777777" withdrawal = "spw_77777777-7777-4777-8777-777777777777"
preparation = "sp_88888888-8888-4888-8888-888888888888" preparation = "sp_88888888-8888-4888-8888-888888888888"

View file

@ -170,9 +170,13 @@ def load(path: Path) -> dict:
def build(db: sqlite3.Connection) -> dict: def build(db: sqlite3.Connection) -> dict:
entries = [] 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( rows = db.execute(
"SELECT entry_id,night_start_date,night_end_date,created_at,updated_at,current_revision_id,deleted " "SELECT e.entry_id,e.night_start_date,e.night_end_date,e.created_at,e.updated_at,"
"FROM sleep_diary_entries ORDER BY entry_id" "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() ).fetchall()
if len(rows) > MAX_ENTRIES: if len(rows) > MAX_ENTRIES:
fail("sleep diary entry bound exceeded") fail("sleep diary entry bound exceeded")
@ -241,6 +245,15 @@ def apply(db: sqlite3.Connection, root: dict) -> tuple[int, int]:
) )
if persisted != item: if persisted != item:
fail("sleep diary revision identity reused with different content") 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 unchanged += 1
continue continue
if local is not None and item["parent_revision_id"] != local[0]: 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["medication_name"], intake["taken_at"], intake["dose_value"],
intake["dose_unit"], intake["note"], intake["created_at"]) intake["dose_unit"], intake["note"], intake["created_at"])
for intake in item["intakes"]]) 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 applied += 1
return applied, unchanged return applied, unchanged

View file

@ -456,6 +456,15 @@ def capture_desktop(database: Path, owned_root: Path, consumer: str,
) )
if cursor.rowcount != 1: if cursor.rowcount != 1:
raise GenerationError("program deletion changed during capture") 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() db.commit()
return stage, manifest, checksum return stage, manifest, checksum
except Exception: 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=?", db.execute("UPDATE sync_generations SET status=?,acknowledged_at=? WHERE generation_id=?",
(status, ack["consumed_at"], ack["generation_id"])) (status, ack["consumed_at"], ack["generation_id"]))
if status == "acknowledged": 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( db.execute(
"UPDATE session_preparation_deliveries SET state='acknowledged',acknowledged_at=? " "UPDATE session_preparation_deliveries SET state='acknowledged',acknowledged_at=? "
"WHERE generation_id=? AND state IN('pending','remote_unknown')", "WHERE generation_id=? AND state IN('pending','remote_unknown')",

View file

@ -68,6 +68,13 @@ typedef enum TrainlogSleepEventType {
TRAINLOG_SLEEP_EVENT_DAYTIME_SLEEPINESS TRAINLOG_SLEEP_EVENT_DAYTIME_SLEEPINESS
} TrainlogSleepEventType; } 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 { typedef struct TrainlogSleepEvent {
char event_id[TRAINLOG_SLEEP_EVENT_ID_CAPACITY]; char event_id[TRAINLOG_SLEEP_EVENT_ID_CAPACITY];
TrainlogSleepEventType type; TrainlogSleepEventType type;
@ -91,6 +98,9 @@ typedef struct TrainlogSleepDiaryEntry {
size_t event_count; size_t event_count;
TrainlogMedicationIntake intakes[TRAINLOG_SLEEP_INTAKES_MAX]; TrainlogMedicationIntake intakes[TRAINLOG_SLEEP_INTAKES_MAX];
size_t intake_count; 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; bool deleted;
} TrainlogSleepDiaryEntry; } TrainlogSleepDiaryEntry;
@ -132,6 +142,13 @@ trainlog_sleep_diary_delete(TrainlogDatabase *database,
const char *deleted_at, const char *deleted_at,
char output_revision[TRAINLOG_SLEEP_REVISION_ID_CAPACITY]); 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, typedef TrainlogStatus (*TrainlogMedicationVisitor)(void *context,
const TrainlogMedication *medication); const TrainlogMedication *medication);

View file

@ -37,6 +37,12 @@ TrainlogStatus trainlog_web_sleep_delete_request_json(TrainlogDatabase *database
char **output_json, char **output_json,
size_t *output_size); 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, TrainlogStatus trainlog_web_medication_list_json(TrainlogDatabase *database,
char **output_json, char **output_json,
size_t *output_size); size_t *output_size);

View file

@ -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_medications_active ON sleep_medications(deleted,updated_at);"
"CREATE INDEX IF NOT EXISTS sleep_medication_intakes_time ON " "CREATE INDEX IF NOT EXISTS sleep_medication_intakes_time ON "
"sleep_medication_intakes(taken_at,medication_id);" "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;"; "PRAGMA user_version=29;COMMIT;";
/* WHY: schema v29 was already opened on the private 0.1.4 review installation /* 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)));" "(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_medications_active ON sleep_medications(deleted,updated_at);"
"CREATE INDEX IF NOT EXISTS sleep_medication_intakes_time ON " "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 /* CONTRACT: migration fixtures may retain additive v25 columns while
* deliberately lowering user_version. Add each provenance column only when * deliberately lowering user_version. Add each provenance column only when

View file

@ -419,8 +419,10 @@ static TrainlogStatus load_entry(TrainlogDatabase *database,
"SELECT " "SELECT "
"e.entry_id,e.night_start_date,e.night_end_date,e.created_at,e.updated_at,e.current_" "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." "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 " "treatment_and_notes,p.validated_revision_id,p.acknowledged_revision_id FROM "
"r.revision_id=e.current_revision_id WHERE e.entry_id=?1 AND (?2 OR e.deleted=0)"; "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[] = static const char events_sql[] =
"SELECT event_id,event_type,start_at,end_at FROM sleep_diary_events WHERE revision_id=?1 " "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"; "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 sqlite3_column_text(statement, 11) == NULL
? "" ? ""
: (const char *)sqlite3_column_text(statement, 11)); : (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) { if (sqlite3_finalize(statement) != SQLITE_OK) {
return TRAINLOG_STATUS_DATABASE_ERROR; return TRAINLOG_STATUS_DATABASE_ERROR;
} }
@ -553,6 +580,47 @@ static TrainlogStatus load_entry(TrainlogDatabase *database,
: TRAINLOG_STATUS_DATABASE_ERROR; : 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, &timestamp)) {
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, TrainlogStatus trainlog_sleep_diary_get(TrainlogDatabase *database,
const char *entry_id, const char *entry_id,
bool include_deleted, bool include_deleted,
@ -626,6 +694,10 @@ trainlog_sleep_diary_delete(TrainlogDatabase *database,
static const char sql[] = static const char sql[] =
"UPDATE sleep_diary_entries SET deleted=1,updated_at=?1,current_revision_id=?2 WHERE " "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"; "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; sqlite3_stmt *statement = NULL;
TrainlogSleepDiaryEntry entry; TrainlogSleepDiaryEntry entry;
TrainlogStatus status; TrainlogStatus status;
@ -680,6 +752,31 @@ trainlog_sleep_diary_delete(TrainlogDatabase *database,
: sqlite3_changes(database->connection) == 1 ? TRAINLOG_STATUS_OK : sqlite3_changes(database->connection) == 1 ? TRAINLOG_STATUS_OK
: TRAINLOG_STATUS_CONFLICT; : 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 && if (status == TRAINLOG_STATUS_OK &&
sqlite3_exec(database->connection, "COMMIT", NULL, NULL, NULL) == SQLITE_OK) { sqlite3_exec(database->connection, "COMMIT", NULL, NULL, NULL) == SQLITE_OK) {
(void)snprintf( (void)snprintf(

View file

@ -1407,12 +1407,20 @@ static enum MHD_Result handle_request(void *closure,
} }
return queue_json(connection, MHD_HTTP_OK, json, NULL); 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; char *json = NULL;
size_t json_size = 0U; size_t json_size = 0U;
TrainlogStatus status; TrainlogStatus status;
enum MHD_Result queued; 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 = const char *start =
MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "start_date"); MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "start_date");
const char *end = const char *end =
@ -1438,8 +1446,17 @@ static enum MHD_Result handle_request(void *closure,
connection, MHD_HTTP_FORBIDDEN, "{\"error\":\"mutation_forbidden\"}\n", NULL); connection, MHD_HTTP_FORBIDDEN, "{\"error\":\"mutation_forbidden\"}\n", NULL);
} }
status = strcmp(method, MHD_HTTP_METHOD_POST) == 0 status = strcmp(method, MHD_HTTP_METHOD_POST) == 0
? trainlog_web_sleep_save_json( ? (strcmp(url, "/api/v1/sleep-diary/validate") == 0
context->database, state->body, state->body_size, &json, &json_size) ? 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( : trainlog_web_sleep_delete_request_json(
context->database, state->body, state->body_size, &json, &json_size); context->database, state->body, state->body_size, &json, &json_size);
} else { } else {

View file

@ -63,6 +63,14 @@ static const char *quality_text(TrainlogSleepQuality quality) {
: NULL; : 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) { static bool quality_value(yyjson_val *value, TrainlogSleepQuality *output) {
int index; int index;
if (yyjson_is_null(value)) { 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, "created_at", entry->created_at) ||
!yyjson_mut_obj_add_strcpy(document, object, "updated_at", entry->updated_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, "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, "sleep_quality", entry->sleep_quality) ||
!add_nullable_quality(document, object, "wake_quality", entry->wake_quality) || !add_nullable_quality(document, object, "wake_quality", entry->wake_quality) ||
!add_nullable_quality(document, object, "day_form", entry->day_form) || !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; 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 { typedef struct MedicationListContext {
yyjson_mut_doc *document; yyjson_mut_doc *document;
yyjson_mut_val *items; yyjson_mut_val *items;

View file

@ -50,7 +50,13 @@ int main(void) {
production = NULL; production = NULL;
CHECK(sqlite3_open(path, &raw) == SQLITE_OK); CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(scalar(raw, "PRAGMA user_version") == 29); 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, CHECK(scalar(raw,
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN " "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN "
"('sleep_medications','sleep_medication_revisions'," "('sleep_medications','sleep_medication_revisions',"

View file

@ -136,7 +136,19 @@ int main(void) {
CHECK(trainlog_sleep_diary_get(database, entry.entry_id, false, &loaded) == TRAINLOG_STATUS_OK); CHECK(trainlog_sleep_diary_get(database, entry.entry_id, false, &loaded) == TRAINLOG_STATUS_OK);
CHECK(loaded.event_count == 4U && loaded.intake_count == 2U && CHECK(loaded.event_count == 4U && loaded.intake_count == 2U &&
loaded.intakes[0].dose_value == 5.0 && loaded.intakes[1].dose_value == 10.0 && 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( (void)snprintf(
medication.updated_at, sizeof(medication.updated_at), "2026-10-26T12:00:00+01:00"); medication.updated_at, sizeof(medication.updated_at), "2026-10-26T12:00:00+01:00");
(void)snprintf(medication.name, sizeof(medication.name), "Renamed medication"); (void)snprintf(medication.name, sizeof(medication.name), "Renamed medication");
@ -157,6 +169,9 @@ int main(void) {
loaded.day_form = TRAINLOG_SLEEP_QUALITY_TB; loaded.day_form = TRAINLOG_SLEEP_QUALITY_TB;
(void)snprintf(loaded.updated_at, sizeof(loaded.updated_at), "2026-10-25T19:00:00+01:00"); (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_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; stale.day_form = TRAINLOG_SLEEP_QUALITY_TM;
(void)snprintf(stale.updated_at, sizeof(stale.updated_at), "2026-10-25T20:00:00+01:00"); (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) == CHECK(trainlog_sleep_diary_update(database, first_revision, &stale) ==

View file

@ -14,6 +14,7 @@ export interface SleepEntry {
created_at: string; updated_at: string; revision_id: string created_at: string; updated_at: string; revision_id: string
sleep_quality: SleepQuality | null; wake_quality: SleepQuality | null; day_form: SleepQuality | null sleep_quality: SleepQuality | null; wake_quality: SleepQuality | null; day_form: SleepQuality | null
treatment_and_notes: string; events: SleepEvent[]; intakes: MedicationIntake[] treatment_and_notes: string; events: SleepEvent[]; intakes: MedicationIntake[]
publication_status: 'draft' | 'ready' | 'synchronized' | 'modified'
} }
export interface SleepSnapshot { export interface SleepSnapshot {
api_version: 1; entries: SleepEntry[] 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 sleep_duration_seconds: number; long_awake_duration_seconds: number; nap_duration_seconds: number
average_bed_minute: number | null; average_get_up_minute: number | null } average_bed_minute: number | null; average_get_up_minute: number | null }
} }
export type SleepEntryInput = Omit<SleepEntry, 'revision_id'> & { expected_revision: string | null } export type SleepEntryInput = Omit<SleepEntry, 'revision_id' | 'publication_status'> & {
expected_revision: string | null
}
const object = (value: unknown): value is Record<string, unknown> => const object = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value) 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' && 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.night_end_date === 'string' && typeof value.created_at === 'string' &&
typeof value.updated_at === 'string' && typeof value.revision_id === '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) && 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 && 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' && 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 const saveSleepEntry = async (entry: SleepEntryInput) => await mutation('POST', entry)
export async function validateSleepEntry(entryId: string, revisionId: string): Promise<void> {
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', { export const deleteSleepEntry = async (entry: SleepEntry) => await mutation('DELETE', {
entry_id: entry.entry_id, expected_revision: entry.revision_id, deleted_at: new Date().toISOString(), entry_id: entry.entry_id, expected_revision: entry.revision_id, deleted_at: new Date().toISOString(),
}) })

View file

@ -3,6 +3,7 @@ import { buildSleepDiaryPdf } from './sleepDiaryPdf'
import type { SleepEntry, SleepSnapshot } from '../api/sleepDiary' import type { SleepEntry, SleepSnapshot } from '../api/sleepDiary'
const entry = (index: number): SleepEntry => ({ 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')}`, 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', 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', updated_at: '2026-09-01T18:00:00+02:00', revision_id: `slr_${index}`, sleep_quality: 'B',

View file

@ -39,7 +39,11 @@ function page(snapshot: SleepSnapshot, entries: SleepEntry[], index: number, cou
entries.forEach((entry, rowIndex) => row(stream, entry, 538 - rowIndex * 40)) entries.forEach((entry, rowIndex) => row(stream, entry, 538 - rowIndex * 40))
const y = 520 - entries.length * 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}`)) 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.')) 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('') return stream.join('')
} }

View file

@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { deleteSleepEntry, fetchSleepDiary, fetchSleepMedications, saveSleepEntry, saveSleepMedication, import { deleteSleepEntry, fetchSleepDiary, fetchSleepMedications, saveSleepEntry, saveSleepMedication, validateSleepEntry,
type MedicationIntake, type SleepEntry, type SleepEntryInput, type SleepEvent, type SleepEventType, type MedicationIntake, type SleepEntry, type SleepEntryInput, type SleepEvent, type SleepEventType,
type SleepMedication, type SleepQuality } from '../api/sleepDiary' type SleepMedication, type SleepQuality } from '../api/sleepDiary'
import type { AnalysisPeriod } from '../api/analysis' 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.', 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', 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', 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', 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', 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', 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', 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', 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', 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'] }, qualities: ['Very good', 'Good', 'Average', 'Bad', 'Very bad'] },
} as const } as const
@ -57,23 +61,37 @@ export function SleepDiaryWorkspace({ period, language }: { period: AnalysisPeri
const [editingMedication, setEditingMedication] = useState<SleepMedication | null>(null) const [editingMedication, setEditingMedication] = useState<SleepMedication | null>(null)
const [intakeMedication, setIntakeMedication] = useState(''); const [intakeTime, setIntakeTime] = useState('') const [intakeMedication, setIntakeMedication] = useState(''); const [intakeTime, setIntakeTime] = useState('')
const [intakeDose, setIntakeDose] = useState(''); const [intakeUnit, setIntakeUnit] = useState('mg') const [intakeDose, setIntakeDose] = useState(''); const [intakeUnit, setIntakeUnit] = useState('mg')
const identity = useRef<{ entry_id: string; revision_id: string } | null>(null)
const persistence = useRef<Promise<void>>(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) 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]) return { startDate: start.toLocaleDateString('en-CA'), endDate: end.toLocaleDateString('en-CA') } }, [period])
const reload = () => Promise.all([fetchSleepDiary(range.startDate, range.endDate), fetchSleepMedications()]) const reload = () => Promise.all([fetchSleepDiary(range.startDate, range.endDate), fetchSleepMedications()])
.then(([diary, catalog]) => { setSnapshot(diary); setMedications(catalog) }).catch(() => setError('sleep_diary_unavailable')) .then(([diary, catalog]) => { setSnapshot(diary); setMedications(catalog) }).catch(() => setError('sleep_diary_unavailable'))
useEffect(() => { void reload() }, [period]) useEffect(() => { void reload() }, [period])
const edit = (entry: SleepEntry) => setDraft({ ...entry, expected_revision: entry.revision_id, const edit = (entry: SleepEntry) => { identity.current = { entry_id: entry.entry_id, revision_id: entry.revision_id }
events: entry.events.map((event) => ({ ...event })), intakes: entry.intakes.map((intake) => ({ ...intake })) }) setDraft({ ...entry, expected_revision: entry.revision_id,
const change = <K extends keyof SleepEntryInput>(key: K, value: SleepEntryInput[K]) => setDraft((current) => ({ ...current, [key]: value })) events: entry.events.map((event) => ({ ...event })), intakes: entry.intakes.map((intake) => ({ ...intake })) }) }
const addEvent = () => setDraft((current) => ({ ...current, events: [...current.events, { event_id: '', type: eventType, 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 = <K extends keyof SleepEntryInput>(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() }] })) 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<SleepEvent>) => setDraft((current) => ({ ...current, const changeEvent = (index: number, patch: Partial<SleepEvent>) => mutate((current) => ({ ...current,
events: current.events.map((event, currentIndex) => currentIndex === index ? { ...event, ...patch } : event) })) 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) 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') } 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 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 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, medication_name: medication.name, taken_at: absolute(intakeTime), dose_value: dose,
dose_unit: dose === null ? null : intakeUnit, note: '', created_at: new Date().toISOString() } 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))) } 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 ?? '', default_dose_unit: medDose === '' ? null : medUnit, form: editingMedication?.form ?? '',
note: editingMedication?.note ?? '', active: editingMedication?.active ?? true }); note: editingMedication?.note ?? '', active: editingMedication?.active ?? true });
setMedName(''); setEditingMedication(null); await reload() } 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 <div className="sleep-workspace"> return <div className="sleep-workspace">
<article className="analysis-panel analysis-wide"><div className="sleep-heading"><h2>{t.agenda}</h2><div><button type="button" disabled={!snapshot} onClick={() => snapshot && presentSleepDiaryPdf(buildSleepDiaryPdf(snapshot, language), false)}>{t.preview}</button><button type="button" disabled={!snapshot} onClick={() => snapshot && presentSleepDiaryPdf(buildSleepDiaryPdf(snapshot, language), true)}>{t.export}</button></div></div> <article className="analysis-panel analysis-wide"><div className="sleep-heading"><h2>{t.agenda}</h2><div><button type="button" disabled={!snapshot} onClick={() => snapshot && presentSleepDiaryPdf(buildSleepDiaryPdf(snapshot, language), false)}>{t.preview}</button><button type="button" disabled={!snapshot} onClick={() => snapshot && presentSleepDiaryPdf(buildSleepDiaryPdf(snapshot, language), true)}>{t.export}</button></div></div>
{entries.length === 0 ? <p className="analysis-empty">{t.empty}</p> : <div className="sleep-agenda-scroll"><div className="sleep-agenda" role="table"> {entries.length === 0 ? <p className="analysis-empty">{t.empty}</p> : <div className="sleep-agenda-scroll"><div className="sleep-agenda" role="table">
<div className="sleep-hours" aria-hidden="true">{Array.from({ length: 25 }, (_, index) => <span key={index}>{(18 + index) % 24}</span>)}</div> <div className="sleep-hours" aria-hidden="true">{Array.from({ length: 25 }, (_, index) => <span key={index}>{(18 + index) % 24}</span>)}</div>
{entries.map((entry) => <button type="button" className="sleep-row" key={entry.entry_id} onClick={() => edit(entry)}> {entries.map((entry) => <button type="button" className="sleep-row" key={entry.entry_id} onClick={() => edit(entry)}>
<strong>{t.night} {entry.night_start_date}</strong><span className="sleep-track">{entry.events.map((event) => { <strong>{t.night} {entry.night_start_date}<small>{t.statuses[entry.publication_status]} · {t.continue}</small></strong><span className="sleep-track">{entry.events.map((event) => {
const left = offset(event.start_at, entry.night_start_date); const width = event.end_at ? Math.max(1, offset(event.end_at, entry.night_start_date) - left) : 1 const left = offset(event.start_at, entry.night_start_date); const width = event.end_at ? Math.max(1, offset(event.end_at, entry.night_start_date) - left) : 1
return <i key={event.event_id} className={`sleep-event sleep-${event.type}`} style={{ left: `${left}%`, width: `${width}%` }} return <i key={event.event_id} className={`sleep-event sleep-${event.type}`} style={{ left: `${left}%`, width: `${width}%` }}
title={`${t[event.type]} ${event.start_at}${event.end_at ? ` – ${event.end_at}` : ''}`}>{event.type === 'bed_time' ? '↓' : event.type === 'final_get_up' || event.type === 'night_get_up' ? '↑' : event.type === 'daytime_sleepiness' ? 'S' : ''}</i>})}{entry.intakes.map((intake) => <i key={intake.intake_id} className="sleep-event sleep-medication" style={{ left: `${offset(intake.taken_at, entry.night_start_date)}%`, width: '1%' }} title={`${t.medication}: ${intake.medication_name} — ${intake.dose_value === null ? '—' : `${intake.dose_value} ${intake.dose_unit}`} — ${intake.taken_at}`}>M</i>)}</span> title={`${t[event.type]} ${event.start_at}${event.end_at ? ` – ${event.end_at}` : ''}`}>{event.type === 'bed_time' ? '↓' : event.type === 'final_get_up' || event.type === 'night_get_up' ? '↑' : event.type === 'daytime_sleepiness' ? 'S' : ''}</i>})}{entry.intakes.map((intake) => <i key={intake.intake_id} className="sleep-event sleep-medication" style={{ left: `${offset(intake.taken_at, entry.night_start_date)}%`, width: '1%' }} title={`${t.medication}: ${intake.medication_name} — ${intake.dose_value === null ? '—' : `${intake.dose_value} ${intake.dose_unit}`} — ${intake.taken_at}`}>M</i>)}</span>
@ -104,7 +125,7 @@ export function SleepDiaryWorkspace({ period, language }: { period: AnalysisPeri
<section><h3>{t.medications}</h3><div className="sleep-event-add"><label>{t.medication}<select value={intakeMedication} onChange={(e) => selectMedication(e.target.value)}><option value="" />{medications.filter((item) => item.active).map((item) => <option key={item.medication_id} value={item.medication_id}>{item.name}</option>)}</select></label><label>{t.time}<input type="datetime-local" value={intakeTime} onChange={(e) => setIntakeTime(e.target.value)}/></label><label>{t.dose}<input type="number" min="0" step="any" value={intakeDose} onChange={(e) => setIntakeDose(e.target.value)}/></label><label>{t.unit}<input value={intakeUnit} onChange={(e) => setIntakeUnit(e.target.value)}/></label><button type="button" onClick={addIntake}>{t.intake}</button></div> <section><h3>{t.medications}</h3><div className="sleep-event-add"><label>{t.medication}<select value={intakeMedication} onChange={(e) => selectMedication(e.target.value)}><option value="" />{medications.filter((item) => item.active).map((item) => <option key={item.medication_id} value={item.medication_id}>{item.name}</option>)}</select></label><label>{t.time}<input type="datetime-local" value={intakeTime} onChange={(e) => setIntakeTime(e.target.value)}/></label><label>{t.dose}<input type="number" min="0" step="any" value={intakeDose} onChange={(e) => setIntakeDose(e.target.value)}/></label><label>{t.unit}<input value={intakeUnit} onChange={(e) => setIntakeUnit(e.target.value)}/></label><button type="button" onClick={addIntake}>{t.intake}</button></div>
<ul className="sleep-event-list">{draft.intakes.map((intake, index) => <li key={`${intake.intake_id}-${index}`}><strong>{intake.medication_name}</strong><label>{t.time}<input type="datetime-local" value={localInput(intake.taken_at)} onChange={(e) => change('intakes', draft.intakes.map((item, position) => position === index ? { ...item, taken_at: absolute(e.target.value) } : item))}/></label><label>{t.dose}<input type="number" min="0" step="any" value={intake.dose_value ?? ''} onChange={(e) => change('intakes', draft.intakes.map((item, position) => position === index ? { ...item, dose_value: e.target.value === '' ? null : Number(e.target.value), dose_unit: e.target.value === '' ? null : item.dose_unit ?? 'mg' } : item))}/></label><label>{t.unit}<input value={intake.dose_unit ?? ''} disabled={intake.dose_value === null} onChange={(e) => change('intakes', draft.intakes.map((item, position) => position === index ? { ...item, dose_unit: e.target.value } : item))}/></label><button type="button" aria-label={`${t.remove} ${intake.medication_name}`} onClick={() => change('intakes', draft.intakes.filter((_, item) => item !== index))}>×</button></li>)}</ul></section> <ul className="sleep-event-list">{draft.intakes.map((intake, index) => <li key={`${intake.intake_id}-${index}`}><strong>{intake.medication_name}</strong><label>{t.time}<input type="datetime-local" value={localInput(intake.taken_at)} onChange={(e) => change('intakes', draft.intakes.map((item, position) => position === index ? { ...item, taken_at: absolute(e.target.value) } : item))}/></label><label>{t.dose}<input type="number" min="0" step="any" value={intake.dose_value ?? ''} onChange={(e) => change('intakes', draft.intakes.map((item, position) => position === index ? { ...item, dose_value: e.target.value === '' ? null : Number(e.target.value), dose_unit: e.target.value === '' ? null : item.dose_unit ?? 'mg' } : item))}/></label><label>{t.unit}<input value={intake.dose_unit ?? ''} disabled={intake.dose_value === null} onChange={(e) => change('intakes', draft.intakes.map((item, position) => position === index ? { ...item, dose_unit: e.target.value } : item))}/></label><button type="button" aria-label={`${t.remove} ${intake.medication_name}`} onClick={() => change('intakes', draft.intakes.filter((_, item) => item !== index))}>×</button></li>)}</ul></section>
<label className="sleep-notes">{t.notes}<textarea value={draft.treatment_and_notes} maxLength={16384} onChange={(e) => change('treatment_and_notes', e.target.value)}/></label> <label className="sleep-notes">{t.notes}<textarea value={draft.treatment_and_notes} maxLength={16384} onChange={(e) => change('treatment_and_notes', e.target.value)}/></label>
{error && <p role="alert" className="error-panel">{error}</p>}<div className="sleep-actions"><button type="button" disabled={pending} onClick={() => void save()}>{t.save}</button>{draft.expected_revision && <button type="button" disabled={pending} onClick={async () => { const entry = entries.find((item) => item.entry_id === draft.entry_id); if (entry) { await deleteSleepEntry(entry); setDraft(blank()); await reload() } }}>{t.remove}</button>}</div> {error && <p role="alert" className="error-panel">{error}</p>}<div className="sleep-actions"><button type="button" disabled={pending} onClick={() => void save()}>{t.save}</button><button type="button" disabled={pending} onClick={() => void validate()}>{t.validate}</button>{draft.expected_revision && <button type="button" disabled={pending} onClick={async () => { const entry = entries.find((item) => item.entry_id === draft.entry_id); if (entry) { await deleteSleepEntry(entry); identity.current = null; setDraft(blank()); await reload() } }}>{t.remove}</button>}</div>
</article> </article>
<article className="analysis-panel analysis-wide"><h2>{t.medications}</h2><div className="sleep-event-add"><label>{t.medication}<input value={medName} onChange={(e) => setMedName(e.target.value)}/></label><label>{t.usualDose}<input type="number" min="0" step="any" value={medDose} onChange={(e) => setMedDose(e.target.value)}/></label><label>{t.unit}<input value={medUnit} onChange={(e) => setMedUnit(e.target.value)}/></label><button type="button" onClick={() => void addMedication()}>{editingMedication ? t.save : t.addMedication}</button></div><ul className="sleep-event-list">{medications.map((item) => <li key={item.medication_id}><strong>{item.name}</strong><span>{item.default_dose_value === null ? '—' : `${item.default_dose_value} ${item.default_dose_unit}`}</span><button type="button" onClick={() => { setEditingMedication(item); setMedName(item.name); setMedDose(item.default_dose_value?.toString() ?? ''); setMedUnit(item.default_dose_unit ?? 'mg') }}>{t.modify}</button>{item.active && <button type="button" onClick={async () => { const now = new Date().toISOString(); await saveSleepMedication({ ...item, expected_revision: item.revision_id, created_at: now, updated_at: now, active: false }); await reload() }}>{t.deactivate}</button>}</li>)}</ul></article> <article className="analysis-panel analysis-wide"><h2>{t.medications}</h2><div className="sleep-event-add"><label>{t.medication}<input value={medName} onChange={(e) => setMedName(e.target.value)}/></label><label>{t.usualDose}<input type="number" min="0" step="any" value={medDose} onChange={(e) => setMedDose(e.target.value)}/></label><label>{t.unit}<input value={medUnit} onChange={(e) => setMedUnit(e.target.value)}/></label><button type="button" onClick={() => void addMedication()}>{editingMedication ? t.save : t.addMedication}</button></div><ul className="sleep-event-list">{medications.map((item) => <li key={item.medication_id}><strong>{item.name}</strong><span>{item.default_dose_value === null ? '—' : `${item.default_dose_value} ${item.default_dose_unit}`}</span><button type="button" onClick={() => { setEditingMedication(item); setMedName(item.name); setMedDose(item.default_dose_value?.toString() ?? ''); setMedUnit(item.default_dose_unit ?? 'mg') }}>{t.modify}</button>{item.active && <button type="button" onClick={async () => { const now = new Date().toISOString(); await saveSleepMedication({ ...item, expected_revision: item.revision_id, created_at: now, updated_at: now, active: false }); await reload() }}>{t.deactivate}</button>}</li>)}</ul></article>
<div className="analysis-section-grid"><article className="analysis-panel"><h2>{t.summary}</h2><dl className="analysis-facts"><div><dt>{language === 'fr' ? 'Nuits enregistrées' : 'Recorded nights'}</dt><dd>{entries.length}</dd></div><div><dt>{language === 'fr' ? 'Sommeil déclaré' : 'Declared sleep'}</dt><dd>{Math.round((snapshot?.summary.sleep_duration_seconds ?? 0) / 60)} min</dd></div><div><dt>{language === 'fr' ? 'Longs réveils' : 'Long awakenings'}</dt><dd>{snapshot?.summary.long_awake_count ?? 0}</dd></div><div><dt>{language === 'fr' ? 'Siestes' : 'Naps'}</dt><dd>{snapshot?.summary.nap_count ?? 0}</dd></div></dl></article><article className="analysis-panel"><h2>{t.observations}</h2>{entries.filter((entry) => entry.treatment_and_notes).map((entry) => <p key={entry.entry_id}><strong>{entry.night_start_date}</strong> — {entry.treatment_and_notes}</p>)}</article></div> <div className="analysis-section-grid"><article className="analysis-panel"><h2>{t.summary}</h2><dl className="analysis-facts"><div><dt>{language === 'fr' ? 'Nuits enregistrées' : 'Recorded nights'}</dt><dd>{entries.length}</dd></div><div><dt>{language === 'fr' ? 'Sommeil déclaré' : 'Declared sleep'}</dt><dd>{Math.round((snapshot?.summary.sleep_duration_seconds ?? 0) / 60)} min</dd></div><div><dt>{language === 'fr' ? 'Longs réveils' : 'Long awakenings'}</dt><dd>{snapshot?.summary.long_awake_count ?? 0}</dd></div><div><dt>{language === 'fr' ? 'Siestes' : 'Naps'}</dt><dd>{snapshot?.summary.nap_count ?? 0}</dd></div></dl></article><article className="analysis-panel"><h2>{t.observations}</h2>{entries.filter((entry) => entry.treatment_and_notes).map((entry) => <p key={entry.entry_id}><strong>{entry.night_start_date}</strong> — {entry.treatment_and_notes}</p>)}</article></div>