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
dose/unit, synchronized and rendered factually in the agenda and PDF without
efficacy, interaction, causality, dosage, or clinical advice.
Progressive daily capture now persists every edit locally while keeping
draft, ready, synchronized, and post-sync modified states distinct. Explicit
validation publishes only the guarded current revision through the existing
full-generation/ACK path; validation never makes a day immutable.
Development opened after stable v0.1.3 release.

View file

@ -412,6 +412,16 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
arrayOf(operationId, generationId, if (existed) 0 else 1),
)
}
val sleep = JSONObject(checkNotNull(artifacts["sleep-diary"]))
val sleepEntries = sleep.getJSONArray("entries")
for (index in 0 until sleepEntries.length()) {
val entry = sleepEntries.getJSONObject(index)
db.execSQL(
"INSERT INTO sleep_diary_generation_entries(generation_id,entry_id,revision_id) " +
"VALUES(?,?,?)",
arrayOf(generationId, entry.getString("entry_id"), entry.getString("revision_id")),
)
}
}
return CapturedSyncGeneration(generationId, runId, digest, stage)
} catch (error: Exception) {
@ -1039,6 +1049,17 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
ack.getString("generation_id"),
),
)
if (target == "acknowledged") {
db.execSQL(
"UPDATE sleep_diary_publication_state SET acknowledged_revision_id=(SELECT " +
"m.revision_id FROM sleep_diary_generation_entries m WHERE " +
"m.generation_id=? AND m.entry_id=sleep_diary_publication_state.entry_id)," +
"acknowledged_at=? WHERE EXISTS(SELECT 1 FROM sleep_diary_generation_entries m " +
"WHERE m.generation_id=? AND m.entry_id=sleep_diary_publication_state.entry_id)",
arrayOf(ack.getString("generation_id"), ack.getString("consumed_at"),
ack.getString("generation_id")),
)
}
target
}
}

View file

@ -45,6 +45,7 @@ import com.labfytools.trainlog.model.SyncedProgramSummary
import com.labfytools.trainlog.model.SleepDiaryDraft
import com.labfytools.trainlog.model.SleepDiaryEntry
import com.labfytools.trainlog.model.SleepDiaryEvent
import com.labfytools.trainlog.model.SleepPublicationStatus
import com.labfytools.trainlog.model.MedicationIntake
import com.labfytools.trainlog.model.SleepMedication
import com.labfytools.trainlog.model.SleepEventType
@ -563,7 +564,11 @@ class TrainlogRepository(
fun listSleepDiary(limit: Int = 90): List<SleepDiaryEntry> {
require(limit in 1..3660)
val db = database.readableDatabase
return db.rawQuery("SELECT entry_id,night_start_date,night_end_date,created_at,updated_at,current_revision_id FROM sleep_diary_entries WHERE deleted=0 ORDER BY night_start_date DESC,entry_id LIMIT ?", arrayOf(limit.toString())).use { cursor ->
return db.rawQuery("SELECT e.entry_id,e.night_start_date,e.night_end_date,e.created_at," +
"e.updated_at,e.current_revision_id,p.validated_revision_id,p.acknowledged_revision_id " +
"FROM sleep_diary_entries e LEFT JOIN sleep_diary_publication_state p ON " +
"p.entry_id=e.entry_id WHERE e.deleted=0 ORDER BY e.night_start_date DESC,e.entry_id LIMIT ?",
arrayOf(limit.toString())).use { cursor ->
buildList {
while (cursor.moveToNext()) {
val revision = cursor.getString(5)
@ -571,12 +576,41 @@ class TrainlogRepository(
val events = db.rawQuery("SELECT event_id,event_type,start_at,end_at FROM sleep_diary_events WHERE revision_id=? ORDER BY start_at,event_id", arrayOf(revision)).use { eventCursor -> buildList { while (eventCursor.moveToNext()) add(SleepDiaryEvent(eventCursor.getString(0), SleepEventType.entries.single { it.wireValue == eventCursor.getString(1) }, eventCursor.getString(2), if (eventCursor.isNull(3)) null else eventCursor.getString(3))) } }
val intakes = db.rawQuery("SELECT intake_id,medication_id,medication_name,taken_at,dose_value,dose_unit,note,created_at FROM sleep_medication_intakes WHERE revision_id=? ORDER BY taken_at,intake_id", arrayOf(revision)).use { intakeCursor -> buildList { while (intakeCursor.moveToNext()) add(MedicationIntake(intakeCursor.getString(0), intakeCursor.getString(1), intakeCursor.getString(2), intakeCursor.getString(3), if (intakeCursor.isNull(4)) null else intakeCursor.getDouble(4), if (intakeCursor.isNull(5)) null else intakeCursor.getString(5), intakeCursor.getString(6), intakeCursor.getString(7))) } }
fun quality(value: String?) = value?.let { text -> SleepQuality.entries.single { it.wireValue == text } }
add(SleepDiaryEntry(cursor.getString(0),cursor.getString(1),cursor.getString(2),cursor.getString(3),cursor.getString(4),revision,quality(values[0]),quality(values[1]),quality(values[2]),values[3].orEmpty(),events,intakes))
val validated = if (cursor.isNull(6)) null else cursor.getString(6)
val acknowledged = if (cursor.isNull(7)) null else cursor.getString(7)
val status = when {
acknowledged == revision -> SleepPublicationStatus.SYNCHRONIZED
acknowledged != null -> SleepPublicationStatus.MODIFIED
validated == revision -> SleepPublicationStatus.READY
else -> SleepPublicationStatus.DRAFT
}
add(SleepDiaryEntry(cursor.getString(0),cursor.getString(1),cursor.getString(2),cursor.getString(3),cursor.getString(4),revision,quality(values[0]),quality(values[1]),quality(values[2]),values[3].orEmpty(),events,intakes,status))
}
}
}
}
/** Marks only the guarded local tip publishable; transport remains owned
* by the existing full-generation synchronization action. */
fun validateSleepDiary(entryId: String, expectedRevision: String, validatedAt: String): SaveSleepDiaryResult {
if (!sleepIdentity(entryId, "sl") || !sleepIdentity(expectedRevision, "slr") ||
try { OffsetDateTime.parse(validatedAt); false } catch (_: RuntimeException) { true }) {
return SaveSleepDiaryResult.Invalid
}
val statement = database.writableDatabase.compileStatement(
"INSERT INTO sleep_diary_publication_state(entry_id,validated_revision_id,validated_at) " +
"SELECT entry_id,current_revision_id,? FROM sleep_diary_entries WHERE entry_id=? " +
"AND current_revision_id=? AND deleted=0 ON CONFLICT(entry_id) DO UPDATE SET " +
"validated_revision_id=excluded.validated_revision_id,validated_at=excluded.validated_at",
)
statement.bindString(1, validatedAt); statement.bindString(2, entryId)
statement.bindString(3, expectedRevision)
return try {
if (statement.executeInsert() < 0L) SaveSleepDiaryResult.Conflict
else SaveSleepDiaryResult.Saved(entryId, expectedRevision)
} catch (_: android.database.sqlite.SQLiteException) { SaveSleepDiaryResult.Conflict }
}
fun listSleepMedications(includeInactive: Boolean = true): List<SleepMedication> {
val db = database.readableDatabase
return db.rawQuery(
@ -670,6 +704,12 @@ class TrainlogRepository(
guarded.bindString(3, entryId)
guarded.bindString(4, expectedRevision)
if (guarded.executeUpdateDelete() != 1) return SaveSleepDiaryResult.Conflict
db.execSQL(
"INSERT INTO sleep_diary_publication_state(entry_id,validated_revision_id,validated_at) " +
"VALUES(?,?,?) ON CONFLICT(entry_id) DO UPDATE SET " +
"validated_revision_id=excluded.validated_revision_id,validated_at=excluded.validated_at",
arrayOf(entryId, revisionId, deletedAt),
)
db.setTransactionSuccessful()
SaveSleepDiaryResult.Saved(entryId, revisionId)
} catch (_: android.database.sqlite.SQLiteException) {
@ -684,8 +724,10 @@ class TrainlogRepository(
val entries = JSONArray()
val db = database.readableDatabase
db.rawQuery(
"SELECT entry_id,night_start_date,night_end_date,created_at,updated_at,current_revision_id,deleted " +
"FROM sleep_diary_entries ORDER BY entry_id",
"SELECT e.entry_id,e.night_start_date,e.night_end_date,e.created_at,e.updated_at," +
"e.current_revision_id,e.deleted FROM sleep_diary_entries e JOIN " +
"sleep_diary_publication_state p ON p.entry_id=e.entry_id AND " +
"p.validated_revision_id=e.current_revision_id ORDER BY e.entry_id",
null,
).use { cursor ->
while (cursor.moveToNext()) {
@ -881,6 +923,7 @@ class TrainlogRepository(
if (!sameEvents) return SleepDiaryImportResult.Rejected(
"sleep diary revision identity reused with different content",
)
markSleepDiaryImported(db, entryId, revisionId, root.getString("generated_at"))
unchanged++
continue
}
@ -936,6 +979,7 @@ class TrainlogRepository(
intake.medicationName, intake.takenAt, intake.doseValue, intake.doseUnit,
intake.note, intake.createdAt),
) }
markSleepDiaryImported(db, entryId, revisionId, root.getString("generated_at"))
advanced++
}
SleepDiaryImportResult.Applied(advanced, unchanged)
@ -944,6 +988,22 @@ class TrainlogRepository(
}
}
private fun markSleepDiaryImported(
db: SQLiteDatabase,
entryId: String,
revisionId: String,
synchronizedAt: String,
) {
db.execSQL(
"INSERT INTO sleep_diary_publication_state(entry_id,validated_revision_id,validated_at," +
"acknowledged_revision_id,acknowledged_at) VALUES(?,?,?,?,?) ON CONFLICT(entry_id) " +
"DO UPDATE SET validated_revision_id=excluded.validated_revision_id," +
"validated_at=excluded.validated_at,acknowledged_revision_id=excluded.acknowledged_revision_id," +
"acknowledged_at=excluded.acknowledged_at",
arrayOf(entryId, revisionId, synchronizedAt, revisionId, synchronizedAt),
)
}
/** Create a transactionally consistent SQLite snapshot without exposing WAL files. */
internal fun backupDatabaseTo(destination: File) {
require(!destination.exists()) { "Backup snapshot destination already exists." }
@ -9602,6 +9662,7 @@ private class TrainlogDatabaseHelper(
)""".trimIndent(),
)
db.execSQL("CREATE INDEX IF NOT EXISTS sleep_diary_entries_dates ON sleep_diary_entries(night_start_date,deleted)")
db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS sleep_diary_one_night ON sleep_diary_entries(night_start_date)")
db.execSQL("CREATE INDEX IF NOT EXISTS sleep_diary_events_time ON sleep_diary_events(start_at,end_at)")
db.execSQL("""CREATE TABLE IF NOT EXISTS sleep_medications(
medication_id TEXT PRIMARY KEY, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
@ -9624,6 +9685,17 @@ private class TrainlogDatabaseHelper(
(dose_value>0 AND length(trim(dose_unit)) BETWEEN 1 AND 32)))""".trimIndent())
db.execSQL("CREATE INDEX IF NOT EXISTS sleep_medications_active ON sleep_medications(deleted,updated_at)")
db.execSQL("CREATE INDEX IF NOT EXISTS sleep_medication_intakes_time ON sleep_medication_intakes(taken_at,medication_id)")
db.execSQL("""CREATE TABLE IF NOT EXISTS sleep_diary_publication_state(
entry_id TEXT PRIMARY KEY REFERENCES sleep_diary_entries(entry_id) ON DELETE RESTRICT,
validated_revision_id TEXT, validated_at TEXT, acknowledged_revision_id TEXT,
acknowledged_at TEXT,
CHECK((validated_revision_id IS NULL)=(validated_at IS NULL)),
CHECK((acknowledged_revision_id IS NULL)=(acknowledged_at IS NULL)))""".trimIndent())
db.execSQL("""CREATE TABLE IF NOT EXISTS sleep_diary_generation_entries(
generation_id TEXT NOT NULL REFERENCES sync_generations(generation_id) ON DELETE RESTRICT,
entry_id TEXT NOT NULL REFERENCES sleep_diary_entries(entry_id) ON DELETE RESTRICT,
revision_id TEXT NOT NULL REFERENCES sleep_diary_revisions(revision_id) ON DELETE RESTRICT,
PRIMARY KEY(generation_id,entry_id))""".trimIndent())
}
private fun createSyncDataLifecycleTables(db: SQLiteDatabase) {

View file

@ -6,6 +6,7 @@ enum class SleepEventType(val wireValue: String) {
SLEEP("sleep"), NAP("nap"), LONG_AWAKE("long_awake"), HALF_SLEEP("half_sleep"),
DAYTIME_SLEEPINESS("daytime_sleepiness"),
}
enum class SleepPublicationStatus { DRAFT, READY, SYNCHRONIZED, MODIFIED }
data class SleepDiaryEvent(val eventId: String, val type: SleepEventType, val startAt: String, val endAt: String?)
data class SleepMedication(
val medicationId: String, val revisionId: String, val createdAt: String, val updatedAt: String,
@ -22,6 +23,7 @@ data class SleepDiaryEntry(
val createdAt: String, val updatedAt: String, val revisionId: String,
val sleepQuality: SleepQuality?, val wakeQuality: SleepQuality?, val dayForm: SleepQuality?,
val treatmentAndNotes: String, val events: List<SleepDiaryEvent>, val intakes: List<MedicationIntake>,
val publicationStatus: SleepPublicationStatus,
)
data class SleepDiaryDraft(
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.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -29,10 +30,12 @@ import com.labfytools.trainlog.model.SleepDiaryEntry
import com.labfytools.trainlog.model.SleepDiaryEvent
import com.labfytools.trainlog.model.SleepEventType
import com.labfytools.trainlog.model.SleepQuality
import com.labfytools.trainlog.model.SleepPublicationStatus
import com.labfytools.trainlog.model.MedicationIntake
import com.labfytools.trainlog.model.SleepMedication
import java.time.LocalDate
import java.time.OffsetDateTime
import java.util.UUID
@Composable
fun SleepDiaryScreen(repository: TrainlogRepository) {
@ -56,7 +59,22 @@ fun SleepDiaryScreen(repository: TrainlogRepository) {
var medicationDose by remember { mutableStateOf("") }
var medicationUnit by remember { mutableStateOf("mg") }
var editingMedication by remember { mutableStateOf<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(
modifier = Modifier.fillMaxSize().padding(12.dp),
@ -67,6 +85,12 @@ fun SleepDiaryScreen(repository: TrainlogRepository) {
Card(onClick = { selected = entry }) {
Column(Modifier.fillMaxWidth().padding(12.dp)) {
Text("${entry.nightStartDate} → ${entry.nightEndDate}")
Text(stringResource(when (entry.publicationStatus) {
SleepPublicationStatus.DRAFT -> R.string.sleep_status_draft
SleepPublicationStatus.READY -> R.string.sleep_status_ready
SleepPublicationStatus.SYNCHRONIZED -> R.string.sleep_status_synchronized
SleepPublicationStatus.MODIFIED -> R.string.sleep_status_modified
}))
Text("${entry.events.size} · ${entry.sleepQuality?.wireValue ?: "—"} / ${entry.wakeQuality?.wireValue ?: "—"}")
}
}
@ -76,18 +100,19 @@ fun SleepDiaryScreen(repository: TrainlogRepository) {
Column(Modifier.fillMaxWidth().padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(stringResource(R.string.sleep_morning))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(startDate, { startDate = it }, label = { Text("YYYY-MM-DD") }, modifier = Modifier.weight(1f))
OutlinedTextField(endDate, { endDate = it }, label = { Text("YYYY-MM-DD") }, modifier = Modifier.weight(1f))
OutlinedTextField(startDate, { startDate = it; changed() }, label = { Text("YYYY-MM-DD") }, modifier = Modifier.weight(1f))
OutlinedTextField(endDate, { endDate = it; changed() }, label = { Text("YYYY-MM-DD") }, modifier = Modifier.weight(1f))
}
QualityRow(stringResource(R.string.sleep_quality), sleepQuality) { sleepQuality = it }
QualityRow(stringResource(R.string.sleep_wake_quality), wakeQuality) { wakeQuality = it }
QualityRow(stringResource(R.string.sleep_quality), sleepQuality) { sleepQuality = it; changed() }
QualityRow(stringResource(R.string.sleep_wake_quality), wakeQuality) { wakeQuality = it; changed() }
Text(stringResource(R.string.sleep_day))
QualityRow(stringResource(R.string.sleep_day_form), dayForm) { dayForm = it }
QualityRow(stringResource(R.string.sleep_day_form), dayForm) { dayForm = it; changed() }
SleepEventType.entries.forEach { type ->
Button(onClick = {
val start = OffsetDateTime.now()
events = events + SleepDiaryEvent("", type, start.toString(),
events = events + SleepDiaryEvent("sle_${UUID.randomUUID()}", type, start.toString(),
if (type in pointTypes) null else start.plusHours(1).toString())
changed()
}) { Text(eventLabel(type)) }
}
events.forEachIndexed { index, event ->
@ -98,17 +123,19 @@ fun SleepDiaryScreen(repository: TrainlogRepository) {
events = events.mapIndexed { item, current ->
if (item == index) current.copy(startAt = value) else current
}
changed()
}
OutlinedTextField(event.startAt, { value -> events = events.mapIndexed { item, current -> if (item == index) current.copy(startAt = value) else current } }, label = { Text("ISO-8601") })
OutlinedTextField(event.startAt, { value -> events = events.mapIndexed { item, current -> if (item == index) current.copy(startAt = value) else current }; changed() }, label = { Text("ISO-8601") })
event.endAt?.let { end ->
TimeButton(end) { value ->
events = events.mapIndexed { item, current ->
if (item == index) current.copy(endAt = value) else current
}
changed()
}
OutlinedTextField(end, { value -> events = events.mapIndexed { item, current -> if (item == index) current.copy(endAt = value) else current } }, label = { Text("ISO-8601 →") })
OutlinedTextField(end, { value -> events = events.mapIndexed { item, current -> if (item == index) current.copy(endAt = value) else current }; changed() }, label = { Text("ISO-8601 →") })
}
Button(onClick = { events = events.filterIndexed { item, _ -> item != index } }) { Text(stringResource(R.string.sleep_delete)) }
Button(onClick = { events = events.filterIndexed { item, _ -> item != index }; changed() }) { Text(stringResource(R.string.sleep_delete)) }
}
}
}
@ -162,9 +189,10 @@ fun SleepDiaryScreen(repository: TrainlogRepository) {
val dose = intakeDose.toDoubleOrNull()
if (intakeDose.isEmpty() || dose != null && dose > 0.0) {
val now = OffsetDateTime.now().toString()
intakes = intakes + MedicationIntake("", medication.medicationId,
intakes = intakes + MedicationIntake("mdi_${UUID.randomUUID()}", medication.medicationId,
medication.name, now, dose, if (dose == null) null else intakeUnit,
"", now)
changed()
}
}
}) { Text(stringResource(R.string.sleep_add_intake)) }
@ -172,28 +200,35 @@ fun SleepDiaryScreen(repository: TrainlogRepository) {
Column(Modifier.fillMaxWidth().padding(8.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(intake.medicationName)
TimeButton(intake.takenAt) { value -> intakes = intakes.mapIndexed { item, current ->
if (item == index) current.copy(takenAt = value) else current } }
if (item == index) current.copy(takenAt = value) else current }; changed() }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(intake.doseValue?.toString().orEmpty(), { value ->
intakes = intakes.mapIndexed { item, current -> if (item == index) current.copy(
doseValue = value.toDoubleOrNull(), doseUnit = if (value.isEmpty()) null else current.doseUnit ?: "mg") else current }
changed()
}, label = { Text(stringResource(R.string.sleep_dose)) }, modifier = Modifier.weight(1f))
OutlinedTextField(intake.doseUnit.orEmpty(), { value -> intakes = intakes.mapIndexed { item, current ->
if (item == index) current.copy(doseUnit = value) else current }
changed()
}, label = { Text(stringResource(R.string.sleep_unit)) }, modifier = Modifier.weight(1f))
}
Button(onClick = { intakes = intakes.filterIndexed { item, _ -> item != index } }) {
Button(onClick = { intakes = intakes.filterIndexed { item, _ -> item != index }; changed() }) {
Text(stringResource(R.string.sleep_delete))
}
}
} }
OutlinedTextField(notes, { notes = it }, label = { Text(stringResource(R.string.sleep_notes)) }, modifier = Modifier.fillMaxWidth(), minLines = 3)
OutlinedTextField(notes, { notes = it; changed() }, label = { Text(stringResource(R.string.sleep_notes)) }, modifier = Modifier.fillMaxWidth(), minLines = 3)
Button(onClick = {
val now = OffsetDateTime.now().toString()
val current = selected
val result = repository.saveSleepDiary(SleepDiaryDraft(current?.entryId,current?.revisionId,startDate,endDate,current?.createdAt ?: now,now,sleepQuality,wakeQuality,dayForm,notes,events,intakes))
if (result is TrainlogRepository.SaveSleepDiaryResult.Saved) { revision++; selected = repository.listSleepDiary().firstOrNull { it.entryId == result.entryId } }
}) { Text(stringResource(R.string.sleep_save)) }
selected?.let { entry -> Button(onClick = {
repository.validateSleepDiary(entry.entryId, entry.revisionId,
OffsetDateTime.now().toString())
selected = repository.listSleepDiary().firstOrNull { it.entryId == entry.entryId }
}) { Text(stringResource(R.string.sleep_validate)) } }
selected?.let { entry -> Button(onClick = { repository.deleteSleepDiary(entry.entryId,entry.revisionId,OffsetDateTime.now().toString()); selected = null; revision++ }) { Text(stringResource(R.string.sleep_delete)) } }
}
}

View file

@ -26,6 +26,11 @@
<string name="sleep_notes">Treatment and notes</string>
<string name="sleep_add_event">Add event</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_choose_time">Choose time</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_add_event">Ajouter un événement</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_choose_time">Choisir l’heure</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.SleepEventType
import com.labfytools.trainlog.model.SleepQuality
import com.labfytools.trainlog.model.SleepPublicationStatus
import com.labfytools.trainlog.model.MedicationIntake
import com.labfytools.trainlog.model.SleepMedication
import java.util.UUID
@ -39,6 +40,15 @@ class SleepDiaryRepositoryTest {
"Synthetic medication", "2026-10-25T05:15:00+01:00", 10.0, "mg", "", catalogTime)))
val created = repository.saveSleepDiary(initial) as TrainlogRepository.SaveSleepDiaryResult.Saved
val loaded = repository.listSleepDiary().single()
assertEquals(SleepPublicationStatus.DRAFT, loaded.publicationStatus)
assertEquals(0, org.json.JSONObject(repository.buildSleepDiaryV1Json())
.getJSONArray("entries").length())
assertTrue(repository.validateSleepDiary(loaded.entryId, loaded.revisionId,
"2026-10-25T17:30:00+01:00") is TrainlogRepository.SaveSleepDiaryResult.Saved)
assertEquals(SleepPublicationStatus.READY,
repository.listSleepDiary().single().publicationStatus)
assertEquals(1, org.json.JSONObject(repository.buildSleepDiaryV1Json())
.getJSONArray("entries").length())
assertEquals(2, loaded.events.size)
assertEquals(10.0, loaded.intakes.single().doseValue!!, 0.0)
val medication = repository.listSleepMedications().single()
@ -56,6 +66,7 @@ class SleepDiaryRepositoryTest {
repository.close(); repository = TrainlogRepository(context, name)
val reopened = repository.listSleepDiary().single()
assertEquals(SleepQuality.MOY, reopened.wakeQuality)
assertEquals(SleepPublicationStatus.DRAFT, reopened.publicationStatus)
assertTrue(repository.deleteSleepDiary(reopened.entryId, reopened.revisionId,
"2026-10-26T12:00:00+01:00") is TrainlogRepository.SaveSleepDiaryResult.Saved)
assertTrue(repository.listSleepDiary().isEmpty())

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,
correction and logical deletion are revision guarded and survive repository
reopen. Full-generation exchange transports the companion, never SQLite.
Each event, intake, appreciation, date or note edit is saved locally at once;
closing the screen or killing the process does not require validation or sync.
The screen resumes the most recent entry and distinguishes draft, ready,
synchronized and modified tips. “Validate day” marks the current revision for
the existing global synchronization action and never makes it immutable.
## Background USB and private Drive synchronization

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
tombstone. Qualities preserve `TB`, `B`, `Moy`, `M`, and `TM` exactly.
`sleep_diary_publication_state` separates the durable local tip from the tip
explicitly validated for publication and the exact revision covered by a
full-generation ACK. `sleep_diary_generation_entries` binds captured revisions
to immutable generation identities. Consequently a new entry is a draft, a
validated tip is ready, an ACKed tip is synchronized, and any later local tip
is modified without rewriting its acknowledged ancestor. One stable entry is
owned by each `night_start_date`; old drafts remain editable indefinitely.
The absolute timestamps are the future heart-rate correlation boundary. Sleep
Diary V1 does not store, infer, or simulate heart-rate data.

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;
`TRAINLOG_FORMAT_V1` remains unchanged and frozen.
Every field edit commits locally as a new immutable diary revision but does
not itself enter a generation. Explicit day validation marks only the guarded
current tip publishable and starts no transport. Generation capture exports
validated current tips, records each exact generation/revision relation, and
only a correlated consumed ACK advances the acknowledged marker. A correction
after ACK therefore remains local and appears as modified until it is
revalidated and acknowledged by a later generation. Imported tips are already
validated and acknowledged because their containing generation was durably
consumed. Draft and synchronized state are not inferred from wall-clock time.
`trainlog-sleep-diary`, version 1, is emitted by full generations on the 0.1.4
Sleep Diary branch. It is optional when consuming an older generation so a
pre-domain artifact remains replayable. It carries bounded complete entry snapshots with

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
rollback, Web periods/deep links, Android persistence, and vector PDF
construction/pagination for 7, 14, 21 and 30 synthetic days.
Lifecycle regressions additionally cover durable drafts excluded from export,
guarded validation, reopen, post-validation correction, exact
generation-to-revision capture and ACK-only acknowledgement.
`web_analysis` covers empty history, every 7/30/90/all period selector,
loaded and unloaded repetitions, continuous duration/distance/speed, explicit

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_medication_revisions(revision_id TEXT PRIMARY KEY,medication_id TEXT,parent_revision_id TEXT,created_at TEXT,name TEXT,default_dose_value REAL,default_dose_unit TEXT,form TEXT,note TEXT,active INTEGER);
CREATE TABLE sleep_medication_intakes(revision_id TEXT,intake_id TEXT,medication_id TEXT,medication_name TEXT,taken_at TEXT,dose_value REAL,dose_unit TEXT,note TEXT,created_at TEXT,PRIMARY KEY(revision_id,intake_id));
CREATE TABLE sleep_diary_publication_state(entry_id TEXT PRIMARY KEY,validated_revision_id TEXT,validated_at TEXT,acknowledged_revision_id TEXT,acknowledged_at TEXT);
""")
def document(self, item):
@ -67,6 +68,20 @@ class SleepExchangeTest(unittest.TestCase):
with self.assertRaisesRegex(ValueError, "positive"):
exchange.validate(self.document(invalid))
def test_draft_is_durable_but_not_exported_until_validated(self):
revision = "slr_20000000-0000-4000-8000-000000000002"
item = entry(revision)
self.db.execute("INSERT INTO sleep_diary_entries VALUES(?,?,?,?,?,?,?)",
(item["entry_id"], item["night_start_date"], item["night_end_date"],
item["created_at"], item["updated_at"], revision, 0))
self.db.execute("INSERT INTO sleep_diary_revisions VALUES(?,?,?,?,?,?,?,?)",
(revision, item["entry_id"], None, item["updated_at"], "B", "Moy", "TB",
item["treatment_and_notes"]))
self.assertEqual([], exchange.build(self.db)["entries"])
self.db.execute("INSERT INTO sleep_diary_publication_state VALUES(?,?,?,NULL,NULL)",
(item["entry_id"], revision, item["updated_at"]))
self.assertEqual(revision, exchange.build(self.db)["entries"][0]["revision_id"])
if __name__ == "__main__":
unittest.main()

View file

@ -71,6 +71,38 @@ class GenerationTest(unittest.TestCase):
with closing(sqlite3.connect(destination)) as reopened:
self.assertEqual(ack,generation.record_consumed(reopened,manifest,checksum));self.assertEqual(1,reopened.execute("SELECT count(*) FROM facts WHERE id='imported'").fetchone()[0])
def test_sleep_revision_becomes_acknowledged_only_after_correlated_ack(self):
stage, manifest, checksum = self.capture()
self.publish()
entry = "sl_10000000-0000-4000-8000-000000000001"
revision = "slr_20000000-0000-4000-8000-000000000002"
with closing(sqlite3.connect(self.database)) as db:
db.executescript("""
CREATE TABLE sleep_diary_publication_state(entry_id TEXT PRIMARY KEY,validated_revision_id TEXT,validated_at TEXT,acknowledged_revision_id TEXT,acknowledged_at TEXT);
CREATE TABLE sleep_diary_generation_entries(generation_id TEXT,entry_id TEXT,revision_id TEXT,PRIMARY KEY(generation_id,entry_id));
CREATE TABLE program_deletions(generation_id TEXT,acknowledged_at TEXT);
PRAGMA user_version=29;
""")
db.execute("INSERT INTO sleep_diary_publication_state VALUES(?,?,?,NULL,NULL)",
(entry, revision, "2026-09-21T17:30:00+02:00"))
db.execute("INSERT INTO sleep_diary_generation_entries VALUES(?,?,?)",
(GEN, entry, revision))
db.execute("UPDATE sync_generations SET status='waiting_acknowledgement' WHERE generation_id=?", (GEN,))
db.commit()
ack = generation.ack_document(
manifest["run_id"], manifest["generation_id"], manifest["producer"]["peer_id"],
manifest["consumer_peer_id"], checksum, "consumed",
"2026-09-21T18:00:00+02:00",
)
ack_path = self.root / "sleep-ack.json"
ack_path.write_bytes(generation.canonical(ack))
self.assertEqual("acknowledged", generation.accept_ack(self.database, ack_path))
with closing(sqlite3.connect(self.database)) as db:
self.assertEqual((revision,), db.execute(
"SELECT acknowledged_revision_id FROM sleep_diary_publication_state WHERE entry_id=?",
(entry,),
).fetchone())
def test_withdrawal_generation_relation_is_durable_until_ack(self):
withdrawal = "spw_77777777-7777-4777-8777-777777777777"
preparation = "sp_88888888-8888-4888-8888-888888888888"

View file

@ -170,9 +170,13 @@ def load(path: Path) -> dict:
def build(db: sqlite3.Connection) -> dict:
entries = []
# CONTRACT: local durability does not publish a draft. A generation may
# capture only the current tip explicitly validated by the user.
rows = db.execute(
"SELECT entry_id,night_start_date,night_end_date,created_at,updated_at,current_revision_id,deleted "
"FROM sleep_diary_entries ORDER BY entry_id"
"SELECT e.entry_id,e.night_start_date,e.night_end_date,e.created_at,e.updated_at,"
"e.current_revision_id,e.deleted FROM sleep_diary_entries e JOIN "
"sleep_diary_publication_state p ON p.entry_id=e.entry_id AND "
"p.validated_revision_id=e.current_revision_id ORDER BY e.entry_id"
).fetchall()
if len(rows) > MAX_ENTRIES:
fail("sleep diary entry bound exceeded")
@ -241,6 +245,15 @@ def apply(db: sqlite3.Connection, root: dict) -> tuple[int, int]:
)
if persisted != item:
fail("sleep diary revision identity reused with different content")
db.execute(
"INSERT INTO sleep_diary_publication_state(entry_id,validated_revision_id,validated_at,"
"acknowledged_revision_id,acknowledged_at) VALUES(?,?,?,?,?) ON CONFLICT(entry_id) "
"DO UPDATE SET validated_revision_id=excluded.validated_revision_id,"
"validated_at=excluded.validated_at,acknowledged_revision_id=excluded.acknowledged_revision_id,"
"acknowledged_at=excluded.acknowledged_at",
(item["entry_id"], item["revision_id"], root["generated_at"],
item["revision_id"], root["generated_at"]),
)
unchanged += 1
continue
if local is not None and item["parent_revision_id"] != local[0]:
@ -269,6 +282,17 @@ def apply(db: sqlite3.Connection, root: dict) -> tuple[int, int]:
intake["medication_name"], intake["taken_at"], intake["dose_value"],
intake["dose_unit"], intake["note"], intake["created_at"])
for intake in item["intakes"]])
# An imported revision has already crossed the synchronization
# boundary; recording both markers prevents it appearing as a draft.
db.execute(
"INSERT INTO sleep_diary_publication_state(entry_id,validated_revision_id,validated_at,"
"acknowledged_revision_id,acknowledged_at) VALUES(?,?,?,?,?) ON CONFLICT(entry_id) "
"DO UPDATE SET validated_revision_id=excluded.validated_revision_id,"
"validated_at=excluded.validated_at,acknowledged_revision_id=excluded.acknowledged_revision_id,"
"acknowledged_at=excluded.acknowledged_at",
(item["entry_id"], item["revision_id"], root["generated_at"],
item["revision_id"], root["generated_at"]),
)
applied += 1
return applied, unchanged

View file

@ -456,6 +456,15 @@ def capture_desktop(database: Path, owned_root: Path, consumer: str,
)
if cursor.rowcount != 1:
raise GenerationError("program deletion changed during capture")
sleep_path = stage / "sleep-diary-v1.json"
if sleep_path.is_file():
captured_sleep = sleep_diary_exchange.load(sleep_path)
db.executemany(
"INSERT INTO sleep_diary_generation_entries(generation_id,entry_id,revision_id) "
"VALUES(?,?,?)",
[(generation, item["entry_id"], item["revision_id"])
for item in captured_sleep["entries"]],
)
db.commit()
return stage, manifest, checksum
except Exception:
@ -730,6 +739,18 @@ def accept_ack(database: Path, path: Path) -> str:
db.execute("UPDATE sync_generations SET status=?,acknowledged_at=? WHERE generation_id=?",
(status, ack["consumed_at"], ack["generation_id"]))
if status == "acknowledged":
# The relation was captured from the immutable staged artifact;
# ACK therefore advances exactly those revisions and never a
# later local edit.
if db.execute("PRAGMA user_version").fetchone()[0] >= 29:
db.execute(
"UPDATE sleep_diary_publication_state SET acknowledged_revision_id=(SELECT "
"m.revision_id FROM sleep_diary_generation_entries m WHERE "
"m.generation_id=? AND m.entry_id=sleep_diary_publication_state.entry_id),"
"acknowledged_at=? WHERE EXISTS(SELECT 1 FROM sleep_diary_generation_entries m "
"WHERE m.generation_id=? AND m.entry_id=sleep_diary_publication_state.entry_id)",
(ack["generation_id"], ack["consumed_at"], ack["generation_id"]),
)
db.execute(
"UPDATE session_preparation_deliveries SET state='acknowledged',acknowledged_at=? "
"WHERE generation_id=? AND state IN('pending','remote_unknown')",

View file

@ -68,6 +68,13 @@ typedef enum TrainlogSleepEventType {
TRAINLOG_SLEEP_EVENT_DAYTIME_SLEEPINESS
} TrainlogSleepEventType;
typedef enum TrainlogSleepPublicationStatus {
TRAINLOG_SLEEP_PUBLICATION_DRAFT = 0,
TRAINLOG_SLEEP_PUBLICATION_READY,
TRAINLOG_SLEEP_PUBLICATION_SYNCHRONIZED,
TRAINLOG_SLEEP_PUBLICATION_MODIFIED
} TrainlogSleepPublicationStatus;
typedef struct TrainlogSleepEvent {
char event_id[TRAINLOG_SLEEP_EVENT_ID_CAPACITY];
TrainlogSleepEventType type;
@ -91,6 +98,9 @@ typedef struct TrainlogSleepDiaryEntry {
size_t event_count;
TrainlogMedicationIntake intakes[TRAINLOG_SLEEP_INTAKES_MAX];
size_t intake_count;
char validated_revision_id[TRAINLOG_SLEEP_REVISION_ID_CAPACITY];
char acknowledged_revision_id[TRAINLOG_SLEEP_REVISION_ID_CAPACITY];
TrainlogSleepPublicationStatus publication_status;
bool deleted;
} TrainlogSleepDiaryEntry;
@ -132,6 +142,13 @@ trainlog_sleep_diary_delete(TrainlogDatabase *database,
const char *deleted_at,
char output_revision[TRAINLOG_SLEEP_REVISION_ID_CAPACITY]);
/* Marks the guarded current revision as complete enough for the existing
* full-generation publisher. It remains editable and no transport is started. */
TrainlogStatus trainlog_sleep_diary_validate_revision(TrainlogDatabase *database,
const char *entry_id,
const char *expected_revision,
const char *validated_at);
typedef TrainlogStatus (*TrainlogMedicationVisitor)(void *context,
const TrainlogMedication *medication);

View file

@ -37,6 +37,12 @@ TrainlogStatus trainlog_web_sleep_delete_request_json(TrainlogDatabase *database
char **output_json,
size_t *output_size);
TrainlogStatus trainlog_web_sleep_validate_json(TrainlogDatabase *database,
const char *body,
size_t body_size,
char **output_json,
size_t *output_size);
TrainlogStatus trainlog_web_medication_list_json(TrainlogDatabase *database,
char **output_json,
size_t *output_size);

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_medication_intakes_time ON "
"sleep_medication_intakes(taken_at,medication_id);"
"CREATE TABLE IF NOT EXISTS sleep_diary_publication_state("
"entry_id TEXT PRIMARY KEY REFERENCES sleep_diary_entries(entry_id) ON DELETE RESTRICT,"
"validated_revision_id TEXT,validated_at TEXT,acknowledged_revision_id TEXT,"
"acknowledged_at TEXT,"
"CHECK((validated_revision_id IS NULL)=(validated_at IS NULL)),"
"CHECK((acknowledged_revision_id IS NULL)=(acknowledged_at IS NULL)));"
"CREATE TABLE IF NOT EXISTS sleep_diary_generation_entries("
"generation_id TEXT NOT NULL REFERENCES sync_generations(generation_id) ON DELETE RESTRICT,"
"entry_id TEXT NOT NULL REFERENCES sleep_diary_entries(entry_id) ON DELETE RESTRICT,"
"revision_id TEXT NOT NULL REFERENCES sleep_diary_revisions(revision_id) ON DELETE RESTRICT,"
"PRIMARY KEY(generation_id,entry_id));"
"PRAGMA user_version=29;COMMIT;";
/* WHY: schema v29 was already opened on the private 0.1.4 review installation
@ -1361,7 +1372,20 @@ static const char *const ENSURE_V29_SLEEP_MEDICATION_SQL =
"(dose_value>0 AND length(trim(dose_unit)) BETWEEN 1 AND 32)));"
"CREATE INDEX IF NOT EXISTS sleep_medications_active ON sleep_medications(deleted,updated_at);"
"CREATE INDEX IF NOT EXISTS sleep_medication_intakes_time ON "
"sleep_medication_intakes(taken_at,medication_id);COMMIT;";
"sleep_medication_intakes(taken_at,medication_id);"
"CREATE UNIQUE INDEX IF NOT EXISTS sleep_diary_one_night ON "
"sleep_diary_entries(night_start_date);"
"CREATE TABLE IF NOT EXISTS sleep_diary_publication_state("
"entry_id TEXT PRIMARY KEY REFERENCES sleep_diary_entries(entry_id) ON DELETE RESTRICT,"
"validated_revision_id TEXT,validated_at TEXT,acknowledged_revision_id TEXT,"
"acknowledged_at TEXT,"
"CHECK((validated_revision_id IS NULL)=(validated_at IS NULL)),"
"CHECK((acknowledged_revision_id IS NULL)=(acknowledged_at IS NULL)));"
"CREATE TABLE IF NOT EXISTS sleep_diary_generation_entries("
"generation_id TEXT NOT NULL REFERENCES sync_generations(generation_id) ON DELETE RESTRICT,"
"entry_id TEXT NOT NULL REFERENCES sleep_diary_entries(entry_id) ON DELETE RESTRICT,"
"revision_id TEXT NOT NULL REFERENCES sleep_diary_revisions(revision_id) ON DELETE RESTRICT,"
"PRIMARY KEY(generation_id,entry_id));COMMIT;";
/* CONTRACT: migration fixtures may retain additive v25 columns while
* deliberately lowering user_version. Add each provenance column only when

View file

@ -419,8 +419,10 @@ static TrainlogStatus load_entry(TrainlogDatabase *database,
"SELECT "
"e.entry_id,e.night_start_date,e.night_end_date,e.created_at,e.updated_at,e.current_"
"revision_id,e.deleted,r.parent_revision_id,r.sleep_quality,r.wake_quality,r.day_form,r."
"treatment_and_notes FROM sleep_diary_entries e JOIN sleep_diary_revisions r ON "
"r.revision_id=e.current_revision_id WHERE e.entry_id=?1 AND (?2 OR e.deleted=0)";
"treatment_and_notes,p.validated_revision_id,p.acknowledged_revision_id FROM "
"sleep_diary_entries e JOIN sleep_diary_revisions r ON "
"r.revision_id=e.current_revision_id LEFT JOIN sleep_diary_publication_state p ON "
"p.entry_id=e.entry_id WHERE e.entry_id=?1 AND (?2 OR e.deleted=0)";
static const char events_sql[] =
"SELECT event_id,event_type,start_at,end_at FROM sleep_diary_events WHERE revision_id=?1 "
"ORDER BY start_at COLLATE BINARY,event_id COLLATE BINARY";
@ -472,6 +474,31 @@ static TrainlogStatus load_entry(TrainlogDatabase *database,
sqlite3_column_text(statement, 11) == NULL
? ""
: (const char *)sqlite3_column_text(statement, 11));
(void)snprintf(output->validated_revision_id,
sizeof(output->validated_revision_id),
"%s",
sqlite3_column_text(statement, 12) == NULL
? ""
: (const char *)sqlite3_column_text(statement, 12));
(void)snprintf(output->acknowledged_revision_id,
sizeof(output->acknowledged_revision_id),
"%s",
sqlite3_column_text(statement, 13) == NULL
? ""
: (const char *)sqlite3_column_text(statement, 13));
/* INVARIANT: durable local edits are independent from publication. An ACK only
* describes the exact revision captured by a completed generation. */
if (output->acknowledged_revision_id[0] != '\0' &&
strcmp(output->acknowledged_revision_id, output->revision_id) == 0) {
output->publication_status = TRAINLOG_SLEEP_PUBLICATION_SYNCHRONIZED;
} else if (output->acknowledged_revision_id[0] != '\0') {
output->publication_status = TRAINLOG_SLEEP_PUBLICATION_MODIFIED;
} else if (output->validated_revision_id[0] != '\0' &&
strcmp(output->validated_revision_id, output->revision_id) == 0) {
output->publication_status = TRAINLOG_SLEEP_PUBLICATION_READY;
} else {
output->publication_status = TRAINLOG_SLEEP_PUBLICATION_DRAFT;
}
if (sqlite3_finalize(statement) != SQLITE_OK) {
return TRAINLOG_STATUS_DATABASE_ERROR;
}
@ -553,6 +580,47 @@ static TrainlogStatus load_entry(TrainlogDatabase *database,
: TRAINLOG_STATUS_DATABASE_ERROR;
}
TrainlogStatus trainlog_sleep_diary_validate_revision(TrainlogDatabase *database,
const char *entry_id,
const char *expected_revision,
const char *validated_at) {
static const char sql[] =
"INSERT INTO sleep_diary_publication_state(entry_id,validated_revision_id,validated_at) "
"SELECT entry_id,current_revision_id,?1 FROM sleep_diary_entries WHERE entry_id=?2 AND "
"current_revision_id=?3 AND deleted=0 ON CONFLICT(entry_id) DO UPDATE SET "
"validated_revision_id=excluded.validated_revision_id,validated_at=excluded.validated_at";
sqlite3_stmt *statement = NULL;
TrainlogTimestampKey timestamp;
int result;
if (database == NULL || !id_valid(entry_id, "sl", TRAINLOG_SLEEP_ENTRY_ID_CAPACITY) ||
!id_valid(expected_revision, "slr", TRAINLOG_SLEEP_REVISION_ID_CAPACITY) ||
!timestamp_valid(validated_at, &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,
const char *entry_id,
bool include_deleted,
@ -626,6 +694,10 @@ trainlog_sleep_diary_delete(TrainlogDatabase *database,
static const char sql[] =
"UPDATE sleep_diary_entries SET deleted=1,updated_at=?1,current_revision_id=?2 WHERE "
"entry_id=?3 AND current_revision_id=?4 AND deleted=0";
static const char publication_sql[] =
"INSERT INTO sleep_diary_publication_state(entry_id,validated_revision_id,validated_at) "
"VALUES(?1,?2,?3) ON CONFLICT(entry_id) DO UPDATE SET "
"validated_revision_id=excluded.validated_revision_id,validated_at=excluded.validated_at";
sqlite3_stmt *statement = NULL;
TrainlogSleepDiaryEntry entry;
TrainlogStatus status;
@ -680,6 +752,31 @@ trainlog_sleep_diary_delete(TrainlogDatabase *database,
: sqlite3_changes(database->connection) == 1 ? TRAINLOG_STATUS_OK
: TRAINLOG_STATUS_CONFLICT;
}
/* Deletion is itself an explicit publication decision. Capturing its
* immutable tombstone preserves causal non-resurrection on the peer. */
if (status == TRAINLOG_STATUS_OK) {
statement = NULL;
result = sqlite3_prepare_v2(database->connection, publication_sql, -1, &statement, NULL);
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 1, entry_id, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 2, entry.revision_id, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 3, deleted_at, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_step(statement) == SQLITE_DONE ? SQLITE_OK
: sqlite3_errcode(database->connection);
}
if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK && result == SQLITE_OK) {
result = SQLITE_ERROR;
}
if (result != SQLITE_OK) {
status = TRAINLOG_STATUS_DATABASE_ERROR;
}
}
if (status == TRAINLOG_STATUS_OK &&
sqlite3_exec(database->connection, "COMMIT", NULL, NULL, NULL) == SQLITE_OK) {
(void)snprintf(

View file

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

View file

@ -63,6 +63,14 @@ static const char *quality_text(TrainlogSleepQuality quality) {
: NULL;
}
static const char *publication_text(TrainlogSleepPublicationStatus status) {
static const char *const values[] = {"draft", "ready", "synchronized", "modified"};
return status >= TRAINLOG_SLEEP_PUBLICATION_DRAFT &&
status <= TRAINLOG_SLEEP_PUBLICATION_MODIFIED
? values[status]
: NULL;
}
static bool quality_value(yyjson_val *value, TrainlogSleepQuality *output) {
int index;
if (yyjson_is_null(value)) {
@ -131,6 +139,8 @@ add_entry(yyjson_mut_doc *document, yyjson_mut_val *items, const TrainlogSleepDi
!yyjson_mut_obj_add_strcpy(document, object, "created_at", entry->created_at) ||
!yyjson_mut_obj_add_strcpy(document, object, "updated_at", entry->updated_at) ||
!yyjson_mut_obj_add_strcpy(document, object, "revision_id", entry->revision_id) ||
!yyjson_mut_obj_add_strcpy(
document, object, "publication_status", publication_text(entry->publication_status)) ||
!add_nullable_quality(document, object, "sleep_quality", entry->sleep_quality) ||
!add_nullable_quality(document, object, "wake_quality", entry->wake_quality) ||
!add_nullable_quality(document, object, "day_form", entry->day_form) ||
@ -553,6 +563,62 @@ TrainlogStatus trainlog_web_sleep_delete_request_json(TrainlogDatabase *database
return status;
}
TrainlogStatus trainlog_web_sleep_validate_json(TrainlogDatabase *database,
const char *body,
size_t body_size,
char **output_json,
size_t *output_size) {
yyjson_read_err error;
yyjson_doc *input;
yyjson_val *root;
const char *entry_id;
const char *revision;
const char *validated_at;
char entry_id_copy[TRAINLOG_SLEEP_ENTRY_ID_CAPACITY];
char revision_copy[TRAINLOG_SLEEP_REVISION_ID_CAPACITY];
TrainlogStatus status;
yyjson_mut_doc *output;
yyjson_mut_val *object;
if (database == NULL || body == NULL || body_size == 0U ||
body_size > TRAINLOG_WEB_SLEEP_BYTES_MAX || output_json == NULL || output_size == NULL) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
input = yyjson_read_opts((char *)body, body_size, YYJSON_READ_NOFLAG, NULL, &error);
root = input == NULL ? NULL : yyjson_doc_get_root(input);
entry_id = yyjson_is_obj(root) && yyjson_is_str(yyjson_obj_get(root, "entry_id"))
? yyjson_get_str(yyjson_obj_get(root, "entry_id"))
: NULL;
revision = yyjson_is_obj(root) && yyjson_is_str(yyjson_obj_get(root, "expected_revision"))
? yyjson_get_str(yyjson_obj_get(root, "expected_revision"))
: NULL;
validated_at = yyjson_is_obj(root) && yyjson_is_str(yyjson_obj_get(root, "validated_at"))
? yyjson_get_str(yyjson_obj_get(root, "validated_at"))
: NULL;
if (entry_id == NULL || revision == NULL || validated_at == NULL ||
yyjson_obj_size(root) != 3U) {
yyjson_doc_free(input);
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
status = trainlog_sleep_diary_validate_revision(database, entry_id, revision, validated_at);
(void)snprintf(entry_id_copy, sizeof(entry_id_copy), "%s", entry_id);
(void)snprintf(revision_copy, sizeof(revision_copy), "%s", revision);
yyjson_doc_free(input);
if (status != TRAINLOG_STATUS_OK) {
return status;
}
output = yyjson_mut_doc_new(NULL);
object = output == NULL ? NULL : yyjson_mut_obj(output);
if (output == NULL || object == NULL ||
!yyjson_mut_obj_add_strcpy(output, object, "entry_id", entry_id_copy) ||
!yyjson_mut_obj_add_strcpy(output, object, "revision_id", revision_copy) ||
!yyjson_mut_obj_add_strcpy(output, object, "publication_status", "ready")) {
yyjson_mut_doc_free(output);
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
yyjson_mut_doc_set_root(output, object);
return write_document(output, output_json, output_size);
}
typedef struct MedicationListContext {
yyjson_mut_doc *document;
yyjson_mut_val *items;

View file

@ -50,7 +50,13 @@ int main(void) {
production = NULL;
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(scalar(raw, "PRAGMA user_version") == 29);
CHECK(scalar(raw, "SELECT COUNT(*) FROM sqlite_master WHERE name LIKE 'sleep_diary_%'") == 5);
CHECK(scalar(raw,
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN "
"('sleep_diary_entries','sleep_diary_revisions','sleep_diary_events',"
"'sleep_diary_publication_state','sleep_diary_generation_entries')") == 5);
CHECK(scalar(raw,
"SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND "
"name='sleep_diary_one_night'") == 1);
CHECK(scalar(raw,
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN "
"('sleep_medications','sleep_medication_revisions',"

View file

@ -136,7 +136,19 @@ int main(void) {
CHECK(trainlog_sleep_diary_get(database, entry.entry_id, false, &loaded) == TRAINLOG_STATUS_OK);
CHECK(loaded.event_count == 4U && loaded.intake_count == 2U &&
loaded.intakes[0].dose_value == 5.0 && loaded.intakes[1].dose_value == 10.0 &&
loaded.sleep_quality == TRAINLOG_SLEEP_QUALITY_B);
loaded.sleep_quality == TRAINLOG_SLEEP_QUALITY_B &&
loaded.publication_status == TRAINLOG_SLEEP_PUBLICATION_DRAFT);
CHECK(trainlog_sleep_diary_validate_revision(
database, entry.entry_id, loaded.revision_id, "2026-10-25T18:00:00+01:00") ==
TRAINLOG_STATUS_OK);
CHECK(trainlog_sleep_diary_get(database, entry.entry_id, false, &loaded) ==
TRAINLOG_STATUS_OK &&
loaded.publication_status == TRAINLOG_SLEEP_PUBLICATION_READY);
CHECK(trainlog_sleep_diary_validate_revision(database,
entry.entry_id,
"slr_00000000-0000-4000-8000-000000000000",
"2026-10-25T18:00:00+01:00") ==
TRAINLOG_STATUS_CONFLICT);
(void)snprintf(
medication.updated_at, sizeof(medication.updated_at), "2026-10-26T12:00:00+01:00");
(void)snprintf(medication.name, sizeof(medication.name), "Renamed medication");
@ -157,6 +169,9 @@ int main(void) {
loaded.day_form = TRAINLOG_SLEEP_QUALITY_TB;
(void)snprintf(loaded.updated_at, sizeof(loaded.updated_at), "2026-10-25T19:00:00+01:00");
CHECK(trainlog_sleep_diary_update(database, first_revision, &loaded) == TRAINLOG_STATUS_OK);
CHECK(trainlog_sleep_diary_get(database, entry.entry_id, false, &loaded) ==
TRAINLOG_STATUS_OK &&
loaded.publication_status == TRAINLOG_SLEEP_PUBLICATION_DRAFT);
stale.day_form = TRAINLOG_SLEEP_QUALITY_TM;
(void)snprintf(stale.updated_at, sizeof(stale.updated_at), "2026-10-25T20:00:00+01:00");
CHECK(trainlog_sleep_diary_update(database, first_revision, &stale) ==

View file

@ -14,6 +14,7 @@ export interface SleepEntry {
created_at: string; updated_at: string; revision_id: string
sleep_quality: SleepQuality | null; wake_quality: SleepQuality | null; day_form: SleepQuality | null
treatment_and_notes: string; events: SleepEvent[]; intakes: MedicationIntake[]
publication_status: 'draft' | 'ready' | 'synchronized' | 'modified'
}
export interface SleepSnapshot {
api_version: 1; entries: SleepEntry[]
@ -21,7 +22,9 @@ export interface SleepSnapshot {
sleep_duration_seconds: number; long_awake_duration_seconds: number; nap_duration_seconds: number
average_bed_minute: number | null; average_get_up_minute: number | null }
}
export type SleepEntryInput = Omit<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> =>
typeof value === 'object' && value !== null && !Array.isArray(value)
@ -34,6 +37,7 @@ function validEntry(value: unknown): value is SleepEntry {
return object(value) && typeof value.entry_id === 'string' && typeof value.night_start_date === 'string' &&
typeof value.night_end_date === 'string' && typeof value.created_at === 'string' &&
typeof value.updated_at === 'string' && typeof value.revision_id === 'string' &&
['draft', 'ready', 'synchronized', 'modified'].includes(String(value.publication_status)) &&
quality(value.sleep_quality) && quality(value.wake_quality) && quality(value.day_form) &&
typeof value.treatment_and_notes === 'string' && Array.isArray(value.events) && value.events.length <= 64 &&
value.events.every((event) => object(event) && typeof event.event_id === 'string' &&
@ -74,6 +78,15 @@ async function mutation(method: 'POST' | 'DELETE', body: unknown): Promise<{ ent
}
export const saveSleepEntry = async (entry: SleepEntryInput) => await mutation('POST', entry)
export async function validateSleepEntry(entryId: string, revisionId: string): Promise<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', {
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'
const entry = (index: number): SleepEntry => ({
publication_status: index === 0 ? 'draft' : 'synchronized',
entry_id: `sl_${index}`, night_start_date: `2026-09-${String(index + 1).padStart(2, '0')}`,
night_end_date: `2026-09-${String(index + 2).padStart(2, '0')}`, created_at: '2026-09-01T18:00:00+02:00',
updated_at: '2026-09-01T18:00:00+02:00', revision_id: `slr_${index}`, sleep_quality: 'B',

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))
const y = 520 - entries.length * 40
stream.push(text(30, y, 9, 'OBSERVATIONS'), text(30, y - 14, 6, `${snapshot.summary.nights} nights; sleep ${Math.round(snapshot.summary.sleep_duration_seconds / 60)} min; long awake ${snapshot.summary.long_awake_count}; naps ${snapshot.summary.nap_count}; sleepiness ${snapshot.summary.sleepiness_count}; intakes ${snapshot.summary.intake_count}`))
entries.filter((entry) => entry.treatment_and_notes).slice(0, 3).forEach((entry, noteIndex) => stream.push(text(30, y - 27 - noteIndex * 10, 6, `${entry.night_start_date}: ${entry.treatment_and_notes.slice(0, 110)}`)))
const unvalidated = snapshot.entries.filter((entry) => entry.publication_status === 'draft' || entry.publication_status === 'modified').length
if (unvalidated > 0) stream.push(text(30, y - 24, 6, language === 'fr'
? `AVERTISSEMENT: ${unvalidated} jour(s) non valide(s) dans cet export.`
: `WARNING: ${unvalidated} unvalidated day(s) in this export.`))
entries.filter((entry) => entry.treatment_and_notes).slice(0, 3).forEach((entry, noteIndex) => stream.push(text(30, y - 36 - noteIndex * 10, 6, `${entry.night_start_date}: ${entry.treatment_and_notes.slice(0, 110)}`)))
stream.push(text(30, 18, 6, 'Legend: v bedtime; ^ get-up; S sleepiness; M medication intake; SLEEP sleep; NAP nap; AWAKE long awakening; HALF half-sleep.'))
return stream.join('')
}

View file

@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react'
import { deleteSleepEntry, fetchSleepDiary, fetchSleepMedications, saveSleepEntry, saveSleepMedication,
import { useEffect, useMemo, useRef, useState } from 'react'
import { deleteSleepEntry, fetchSleepDiary, fetchSleepMedications, saveSleepEntry, saveSleepMedication, validateSleepEntry,
type MedicationIntake, type SleepEntry, type SleepEntryInput, type SleepEvent, type SleepEventType,
type SleepMedication, type SleepQuality } from '../api/sleepDiary'
import type { AnalysisPeriod } from '../api/analysis'
@ -16,7 +16,9 @@ const copy = {
save: 'Enregistrer', remove: 'Supprimer', observations: 'Observations', summary: 'Synthèse factuelle', empty: 'Aucune nuit enregistrée.',
preview: 'Prévisualiser', export: 'Exporter PDF', medications: 'Médicaments', addMedication: 'Ajouter un médicament',
medication: 'Médicament', usualDose: 'Dosage habituel', unit: 'Unité', deactivate: 'Désactiver', intake: 'Ajouter une prise',
time: 'Heure', dose: 'Dosage', modify: 'Modifier', qualities: ['Très bon', 'Bon', 'Moyen', 'Mauvais', 'Très mauvais'] },
time: 'Heure', dose: 'Dosage', modify: 'Modifier', validate: 'Valider la journée', continue: 'Continuer',
statuses: { draft: 'Brouillon', ready: 'Prête à synchroniser', synchronized: 'Synchronisée', modified: 'Modifiée depuis la synchronisation' },
qualities: ['Très bon', 'Bon', 'Moyen', 'Mauvais', 'Très mauvais'] },
en: { agenda: 'Diary', edit: 'Entry editor', morning: 'Morning', day: 'Day / Evening', night: 'Night from',
bed_time: 'Bedtime', final_get_up: 'Final get-up', night_get_up: 'Night get-up', sleep: 'Sleep', nap: 'Nap',
long_awake: 'Long awakening', half_sleep: 'Half-sleep', daytime_sleepiness: 'Sleepiness', sleepQuality: 'Sleep quality',
@ -24,6 +26,8 @@ const copy = {
observations: 'Observations', summary: 'Factual summary', empty: 'No recorded nights.', preview: 'Preview', export: 'Export PDF',
medications: 'Medications', addMedication: 'Add a medication', medication: 'Medication', usualDose: 'Usual dose',
unit: 'Unit', deactivate: 'Deactivate', intake: 'Add an intake', time: 'Time', dose: 'Dose', modify: 'Edit',
validate: 'Validate day', continue: 'Continue', statuses: { draft: 'Draft', ready: 'Ready to sync',
synchronized: 'Synchronized', modified: 'Modified since synchronization' },
qualities: ['Very good', 'Good', 'Average', 'Bad', 'Very bad'] },
} as const
@ -57,23 +61,37 @@ export function SleepDiaryWorkspace({ period, language }: { period: AnalysisPeri
const [editingMedication, setEditingMedication] = useState<SleepMedication | null>(null)
const [intakeMedication, setIntakeMedication] = useState(''); const [intakeTime, setIntakeTime] = useState('')
const [intakeDose, setIntakeDose] = useState(''); const [intakeUnit, setIntakeUnit] = useState('mg')
const identity = useRef<{ entry_id: string; revision_id: string } | null>(null)
const persistence = useRef<Promise<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)
return { startDate: start.toLocaleDateString('en-CA'), endDate: end.toLocaleDateString('en-CA') } }, [period])
const reload = () => Promise.all([fetchSleepDiary(range.startDate, range.endDate), fetchSleepMedications()])
.then(([diary, catalog]) => { setSnapshot(diary); setMedications(catalog) }).catch(() => setError('sleep_diary_unavailable'))
useEffect(() => { void reload() }, [period])
const edit = (entry: SleepEntry) => setDraft({ ...entry, expected_revision: entry.revision_id,
events: entry.events.map((event) => ({ ...event })), intakes: entry.intakes.map((intake) => ({ ...intake })) })
const change = <K extends keyof SleepEntryInput>(key: K, value: SleepEntryInput[K]) => setDraft((current) => ({ ...current, [key]: value }))
const addEvent = () => setDraft((current) => ({ ...current, events: [...current.events, { event_id: '', type: eventType,
const edit = (entry: SleepEntry) => { identity.current = { entry_id: entry.entry_id, revision_id: entry.revision_id }
setDraft({ ...entry, expected_revision: entry.revision_id,
events: entry.events.map((event) => ({ ...event })), intakes: entry.intakes.map((intake) => ({ ...intake })) }) }
const persist = (next: SleepEntryInput) => { persistence.current = persistence.current.then(async () => {
const current = identity.current; const result = await saveSleepEntry({ ...next,
entry_id: current?.entry_id ?? next.entry_id, expected_revision: current?.revision_id ?? next.expected_revision,
updated_at: new Date().toISOString() })
identity.current = result
setDraft((value) => ({ ...value, entry_id: result.entry_id, expected_revision: result.revision_id }))
}).catch((reason: unknown) => setError(reason instanceof Error ? reason.message : 'sleep_diary_mutation_failed')) }
const mutate = (transform: (current: SleepEntryInput) => SleepEntryInput) => setDraft((current) => {
const next = transform(current); persist(next); return next
})
const change = <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() }] }))
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) }))
const selectMedication = (id: string) => { setIntakeMedication(id); const item = medications.find((med) => med.medication_id === id)
setIntakeDose(item?.default_dose_value?.toString() ?? ''); setIntakeUnit(item?.default_dose_unit ?? 'mg') }
const addIntake = () => { const medication = medications.find((item) => item.medication_id === intakeMedication); if (!medication || !intakeTime) return
const dose = intakeDose === '' ? null : Number(intakeDose); if (dose !== null && !(dose > 0)) return
const intake: MedicationIntake = { intake_id: '', medication_id: medication.medication_id,
const intake: MedicationIntake = { intake_id: `mdi_${crypto.randomUUID()}`, medication_id: medication.medication_id,
medication_name: medication.name, taken_at: absolute(intakeTime), dose_value: dose,
dose_unit: dose === null ? null : intakeUnit, note: '', created_at: new Date().toISOString() }
change('intakes', [...draft.intakes, intake].sort((a, b) => a.taken_at.localeCompare(b.taken_at))) }
@ -84,13 +102,16 @@ export function SleepDiaryWorkspace({ period, language }: { period: AnalysisPeri
default_dose_unit: medDose === '' ? null : medUnit, form: editingMedication?.form ?? '',
note: editingMedication?.note ?? '', active: editingMedication?.active ?? true });
setMedName(''); setEditingMedication(null); await reload() }
const save = async () => { setPending(true); setError(''); try { await saveSleepEntry({ ...draft, updated_at: new Date().toISOString() }); setDraft(blank()); await reload() } catch (reason) { setError(reason instanceof Error ? reason.message : 'sleep_diary_mutation_failed') } finally { setPending(false) } }
const save = async () => { setPending(true); setError(''); persist(draft); await persistence.current; await reload(); setPending(false) }
const validate = async () => { setPending(true); setError(''); persist(draft); await persistence.current
const current = identity.current; if (current) await validateSleepEntry(current.entry_id, current.revision_id)
await reload(); setPending(false) }
return <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>
{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>
{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
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>
@ -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>
<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>
{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 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>