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