feat(sleep): add structured medication intakes

This commit is contained in:
fy59 2026-09-21 07:01:20 +02:00
parent 53f15e1354
commit afd1fcc92f
26 changed files with 1405 additions and 42 deletions

View file

@ -6,7 +6,10 @@
full-generation synchronization and a deterministic local vector PDF agenda.
Entries retain causal revisions, absolute timestamped events, the five-value
appreciation scale and user-authored notes; no medical interpretation or
heart-rate data is introduced.
heart-rate data is introduced. Sleep Diary also includes a revisioned local
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.
Development opened after stable v0.1.3 release.

View file

@ -45,6 +45,8 @@ 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.MedicationIntake
import com.labfytools.trainlog.model.SleepMedication
import com.labfytools.trainlog.model.SleepEventType
import com.labfytools.trainlog.model.SleepQuality
import com.labfytools.trainlog.model.ProgramSessionExecutionState
@ -501,6 +503,13 @@ class TrainlogRepository(
if (event.type in setOf(SleepEventType.BED_TIME, SleepEventType.FINAL_GET_UP,
SleepEventType.NIGHT_GET_UP, SleepEventType.DAYTIME_SLEEPINESS)) event.endAt == null
else event.endAt != null && OffsetDateTime.parse(event.endAt).toInstant() > start
} && draft.intakes.size <= 32 && draft.intakes.all { intake ->
OffsetDateTime.parse(intake.takenAt)
OffsetDateTime.parse(intake.createdAt)
intake.medicationId.startsWith("med_") && intake.medicationName.isNotBlank() &&
((intake.doseValue == null && intake.doseUnit == null) ||
(intake.doseValue != null && intake.doseValue > 0.0 &&
!intake.doseUnit.isNullOrBlank()))
}
} catch (_: RuntimeException) { false }
@ -536,6 +545,12 @@ class TrainlogRepository(
"INSERT INTO sleep_diary_events VALUES(?,?,?,?,?)",
arrayOf(revisionId,event.eventId.ifEmpty { sleepId("sle") },event.type.wireValue,event.startAt,event.endAt),
) }
draft.intakes.forEach { intake -> db.execSQL(
"INSERT INTO sleep_medication_intakes VALUES(?,?,?,?,?,?,?,?,?)",
arrayOf<Any?>(revisionId, intake.intakeId.ifEmpty { sleepId("mdi") }, intake.medicationId,
intake.medicationName, intake.takenAt, intake.doseValue, intake.doseUnit,
intake.note, intake.createdAt),
) }
db.setTransactionSuccessful()
SaveSleepDiaryResult.Saved(entryId,revisionId)
} catch (_: android.database.sqlite.SQLiteConstraintException) {
@ -554,13 +569,67 @@ class TrainlogRepository(
val revision = cursor.getString(5)
val values = db.rawQuery("SELECT sleep_quality,wake_quality,day_form,treatment_and_notes FROM sleep_diary_revisions WHERE revision_id=?", arrayOf(revision)).use { detail -> check(detail.moveToFirst()); arrayOf(detail.getString(0),detail.getString(1),detail.getString(2),detail.getString(3)) }
val events = db.rawQuery("SELECT event_id,event_type,start_at,end_at FROM sleep_diary_events WHERE revision_id=? ORDER BY start_at,event_id", arrayOf(revision)).use { eventCursor -> buildList { while (eventCursor.moveToNext()) add(SleepDiaryEvent(eventCursor.getString(0), SleepEventType.entries.single { it.wireValue == eventCursor.getString(1) }, eventCursor.getString(2), if (eventCursor.isNull(3)) null else eventCursor.getString(3))) } }
val intakes = db.rawQuery("SELECT intake_id,medication_id,medication_name,taken_at,dose_value,dose_unit,note,created_at FROM sleep_medication_intakes WHERE revision_id=? ORDER BY taken_at,intake_id", arrayOf(revision)).use { intakeCursor -> buildList { while (intakeCursor.moveToNext()) add(MedicationIntake(intakeCursor.getString(0), intakeCursor.getString(1), intakeCursor.getString(2), intakeCursor.getString(3), if (intakeCursor.isNull(4)) null else intakeCursor.getDouble(4), if (intakeCursor.isNull(5)) null else intakeCursor.getString(5), intakeCursor.getString(6), intakeCursor.getString(7))) } }
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))
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))
}
}
}
}
fun listSleepMedications(includeInactive: Boolean = true): List<SleepMedication> {
val db = database.readableDatabase
return db.rawQuery(
"SELECT m.medication_id,m.current_revision_id,m.created_at,m.updated_at,r.name," +
"r.default_dose_value,r.default_dose_unit,r.form,r.note,r.active " +
"FROM sleep_medications m JOIN sleep_medication_revisions r ON " +
"r.revision_id=m.current_revision_id WHERE m.deleted=0 AND (? OR r.active=1) " +
"ORDER BY r.name COLLATE NOCASE,m.medication_id",
arrayOf(if (includeInactive) "1" else "0"),
).use { cursor -> buildList {
while (cursor.moveToNext()) add(SleepMedication(
cursor.getString(0), cursor.getString(1), cursor.getString(2), cursor.getString(3),
cursor.getString(4), if (cursor.isNull(5)) null else cursor.getDouble(5),
if (cursor.isNull(6)) null else cursor.getString(6), cursor.getString(7),
cursor.getString(8), cursor.getInt(9) != 0,
))
} }
}
fun saveSleepMedication(medication: SleepMedication?): SaveSleepDiaryResult {
if (medication == null || medication.name.isBlank() ||
((medication.defaultDoseValue == null) != (medication.defaultDoseUnit == null)) ||
(medication.defaultDoseValue != null && medication.defaultDoseValue <= 0.0)) {
return SaveSleepDiaryResult.Invalid
}
val creating = medication.medicationId.isEmpty()
val id = if (creating) sleepId("med") else medication.medicationId
val next = sleepId("medr")
val db = database.writableDatabase
db.beginTransaction()
return try {
if (creating) db.execSQL("INSERT INTO sleep_medications VALUES(?,?,?,?,0)",
arrayOf(id, medication.createdAt, medication.updatedAt, next))
else {
val statement = db.compileStatement("UPDATE sleep_medications SET updated_at=?," +
"current_revision_id=? WHERE medication_id=? AND current_revision_id=? AND deleted=0")
statement.bindString(1, medication.updatedAt); statement.bindString(2, next)
statement.bindString(3, id); statement.bindString(4, medication.revisionId)
if (statement.executeUpdateDelete() != 1) return SaveSleepDiaryResult.Conflict
}
db.execSQL("INSERT INTO sleep_medication_revisions VALUES(?,?,?,?,?,?,?,?,?,?)",
arrayOf<Any?>(next, id, if (creating) null else medication.revisionId, medication.updatedAt,
medication.name.trim(), medication.defaultDoseValue, medication.defaultDoseUnit,
medication.form, medication.note, if (medication.active) 1 else 0))
db.setTransactionSuccessful()
SaveSleepDiaryResult.Saved(id, next)
} catch (_: android.database.sqlite.SQLiteConstraintException) {
SaveSleepDiaryResult.Conflict
} catch (_: android.database.sqlite.SQLiteException) {
SaveSleepDiaryResult.DatabaseError
} finally { db.endTransaction() }
}
fun deleteSleepDiary(entryId: String, expectedRevision: String, deletedAt: String): SaveSleepDiaryResult {
val revisionId = sleepId("slr")
val db = database.writableDatabase
@ -586,6 +655,12 @@ class TrainlogRepository(
"FROM sleep_diary_events WHERE revision_id=?",
arrayOf(revisionId, expectedRevision),
)
db.execSQL(
"INSERT INTO sleep_medication_intakes SELECT ?,intake_id,medication_id," +
"medication_name,taken_at,dose_value,dose_unit,note,created_at " +
"FROM sleep_medication_intakes WHERE revision_id=?",
arrayOf(revisionId, expectedRevision),
)
val guarded = db.compileStatement(
"UPDATE sleep_diary_entries SET updated_at=?,current_revision_id=?,deleted=1 " +
"WHERE entry_id=? AND current_revision_id=? AND deleted=0",
@ -636,6 +711,17 @@ class TrainlogRepository(
.put("type", values.getString(1)).put("start_at", values.getString(2))
.put("end_at", if (values.isNull(3)) JSONObject.NULL else values.getString(3)),
) }
val intakes = JSONArray()
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 intake_id",
arrayOf(revisionId),
).use { values -> while (values.moveToNext()) intakes.put(JSONObject()
.put("intake_id", values.getString(0)).put("medication_id", values.getString(1))
.put("medication_name", values.getString(2)).put("taken_at", values.getString(3))
.put("dose_value", if (values.isNull(4)) JSONObject.NULL else values.getDouble(4))
.put("dose_unit", if (values.isNull(5)) JSONObject.NULL else values.getString(5))
.put("note", values.getString(6)).put("created_at", values.getString(7))) }
entries.put(JSONObject().put("entry_id", cursor.getString(0))
.put("night_start_date", cursor.getString(1)).put("night_end_date", cursor.getString(2))
.put("created_at", cursor.getString(3)).put("updated_at", cursor.getString(4))
@ -645,11 +731,27 @@ class TrainlogRepository(
.put("sleep_quality", revision[1] ?: JSONObject.NULL)
.put("wake_quality", revision[2] ?: JSONObject.NULL)
.put("day_form", revision[3] ?: JSONObject.NULL)
.put("treatment_and_notes", revision[4]).put("events", events))
.put("treatment_and_notes", revision[4]).put("events", events).put("intakes", intakes))
}
}
val medications = JSONArray()
db.rawQuery("SELECT m.medication_id,m.created_at,m.updated_at,m.current_revision_id,m.deleted," +
"r.parent_revision_id,r.name,r.default_dose_value,r.default_dose_unit,r.form,r.note,r.active " +
"FROM sleep_medications m JOIN sleep_medication_revisions r ON r.revision_id=m.current_revision_id " +
"ORDER BY m.medication_id", null).use { cursor -> while (cursor.moveToNext()) medications.put(
JSONObject().put("medication_id", cursor.getString(0)).put("created_at", cursor.getString(1))
.put("updated_at", cursor.getString(2)).put("revision_id", cursor.getString(3))
.put("deleted", cursor.getInt(4) != 0)
.put("parent_revision_id", if (cursor.isNull(5)) JSONObject.NULL else cursor.getString(5))
.put("name", cursor.getString(6))
.put("default_dose_value", if (cursor.isNull(7)) JSONObject.NULL else cursor.getDouble(7))
.put("default_dose_unit", if (cursor.isNull(8)) JSONObject.NULL else cursor.getString(8))
.put("form", cursor.getString(9)).put("note", cursor.getString(10))
.put("active", cursor.getInt(11) != 0),
) }
return JSONObject().put("format", "trainlog-sleep-diary").put("version", 1)
.put("generated_at", OffsetDateTime.now().toString()).put("entries", entries).toString()
.put("generated_at", OffsetDateTime.now().toString()).put("entries", entries)
.put("medications", medications).toString()
}
internal fun applySleepDiaryV1Json(json: String): SleepDiaryImportResult {
@ -657,7 +759,7 @@ class TrainlogRepository(
return SleepDiaryImportResult.Rejected("invalid JSON")
}
if (!jsonHasUniqueObjectKeys(json) || root.optString("format") != "trainlog-sleep-diary" ||
root.opt("version") != 1 || !root.has("entries"))
root.opt("version") != 1 || !root.has("entries") || !root.has("medications"))
return SleepDiaryImportResult.Rejected("invalid envelope")
val values = root.optJSONArray("entries") ?: return SleepDiaryImportResult.Rejected("invalid entries")
if (values.length() > 3660) return SleepDiaryImportResult.Rejected("entry bound exceeded")
@ -665,6 +767,47 @@ class TrainlogRepository(
var advanced = 0
var unchanged = 0
return try {
val medications = root.getJSONArray("medications")
if (medications.length() > 1000) return SleepDiaryImportResult.Rejected("medication bound exceeded")
for (index in 0 until medications.length()) {
val medication = medications.getJSONObject(index)
val id = medication.getString("medication_id")
val revision = medication.getString("revision_id")
val parent = if (medication.isNull("parent_revision_id")) null else medication.getString("parent_revision_id")
if (!sleepIdentity(id, "med") || !sleepIdentity(revision, "medr") ||
parent != null && !sleepIdentity(parent, "medr"))
return SleepDiaryImportResult.Rejected("invalid medication identity")
val local = db.rawQuery("SELECT current_revision_id,deleted FROM sleep_medications WHERE medication_id=?", arrayOf(id)).use { if (it.moveToFirst()) it.getString(0) to it.getInt(1) else null }
if (local?.first == revision) {
val same = db.rawQuery("SELECT m.created_at,m.updated_at,m.deleted,r.parent_revision_id," +
"r.name,r.default_dose_value,r.default_dose_unit,r.form,r.note,r.active " +
"FROM sleep_medications m JOIN sleep_medication_revisions r ON " +
"r.revision_id=m.current_revision_id WHERE m.medication_id=?", arrayOf(id)).use { current ->
current.moveToFirst() && current.getString(0) == medication.getString("created_at") &&
current.getString(1) == medication.getString("updated_at") &&
(current.getInt(2) != 0) == medication.getBoolean("deleted") &&
(if (current.isNull(3)) null else current.getString(3)) == parent &&
current.getString(4) == medication.getString("name") &&
(if (current.isNull(5)) null else current.getDouble(5)) ==
(if (medication.isNull("default_dose_value")) null else medication.getDouble("default_dose_value")) &&
(if (current.isNull(6)) null else current.getString(6)) ==
(if (medication.isNull("default_dose_unit")) null else medication.getString("default_dose_unit")) &&
current.getString(7) == medication.getString("form") && current.getString(8) == medication.getString("note") &&
(current.getInt(9) != 0) == medication.getBoolean("active")
}
if (!same) return SleepDiaryImportResult.Rejected("medication revision identity reused with different content")
continue
}
if (local != null && local.first != parent) return SleepDiaryImportResult.Rejected("concurrent medication revision")
if (local == null && parent != null) return SleepDiaryImportResult.Rejected("unknown medication parent")
if (local?.second == 1 && !medication.getBoolean("deleted")) return SleepDiaryImportResult.Rejected("medication resurrection")
val dose = if (medication.isNull("default_dose_value")) null else medication.getDouble("default_dose_value")
val unit = if (medication.isNull("default_dose_unit")) null else medication.getString("default_dose_unit")
if ((dose == null) != (unit == null) || dose != null && dose <= 0.0) return SleepDiaryImportResult.Rejected("invalid medication dose")
if (local == null) db.execSQL("INSERT INTO sleep_medications VALUES(?,?,?,?,?)", arrayOf(id, medication.getString("created_at"), medication.getString("updated_at"), revision, if (medication.getBoolean("deleted")) 1 else 0))
else db.execSQL("UPDATE sleep_medications SET updated_at=?,current_revision_id=?,deleted=? WHERE medication_id=? AND current_revision_id=?", arrayOf(medication.getString("updated_at"), revision, if (medication.getBoolean("deleted")) 1 else 0, id, local.first))
db.execSQL("INSERT INTO sleep_medication_revisions VALUES(?,?,?,?,?,?,?,?,?,?)", arrayOf<Any?>(revision, id, parent, medication.getString("updated_at"), medication.getString("name"), dose, unit, medication.getString("form"), medication.getString("note"), if (medication.getBoolean("active")) 1 else 0))
}
for (index in 0 until values.length()) {
val item = values.getJSONObject(index)
val entryId = item.getString("entry_id")
@ -681,7 +824,8 @@ class TrainlogRepository(
val same = db.rawQuery(
"SELECT e.night_start_date,e.night_end_date,e.created_at,e.updated_at,e.deleted," +
"r.parent_revision_id,r.sleep_quality,r.wake_quality,r.day_form,r.treatment_and_notes," +
"(SELECT COUNT(*) FROM sleep_diary_events v WHERE v.revision_id=r.revision_id) " +
"(SELECT COUNT(*) FROM sleep_diary_events v WHERE v.revision_id=r.revision_id)," +
"(SELECT COUNT(*) FROM sleep_medication_intakes i WHERE i.revision_id=r.revision_id) " +
"FROM sleep_diary_entries e JOIN sleep_diary_revisions r " +
"ON r.revision_id=e.current_revision_id WHERE e.entry_id=?",
arrayOf(entryId),
@ -699,7 +843,8 @@ class TrainlogRepository(
(if (current.isNull(8)) null else current.getString(8)) ==
(if (item.isNull("day_form")) null else item.getString("day_form")) &&
current.getString(9).orEmpty() == item.getString("treatment_and_notes") &&
current.getInt(10) == item.getJSONArray("events").length()
current.getInt(10) == item.getJSONArray("events").length() &&
current.getInt(11) == item.getJSONArray("intakes").length()
}
val replayEvents = item.getJSONArray("events")
var sameEvents = same
@ -716,6 +861,23 @@ class TrainlogRepository(
(if (event.isNull("end_at")) null else event.getString("end_at"))
}
}
val replayIntakes = item.getJSONArray("intakes")
for (intakeIndex in 0 until replayIntakes.length()) {
val intake = replayIntakes.getJSONObject(intakeIndex)
sameEvents = sameEvents && db.rawQuery("SELECT medication_id,medication_name," +
"taken_at,dose_value,dose_unit,note,created_at FROM sleep_medication_intakes " +
"WHERE revision_id=? AND intake_id=?", arrayOf(revisionId, intake.getString("intake_id"))).use { current ->
current.moveToFirst() && current.getString(0) == intake.getString("medication_id") &&
current.getString(1) == intake.getString("medication_name") &&
current.getString(2) == intake.getString("taken_at") &&
(if (current.isNull(3)) null else current.getDouble(3)) ==
(if (intake.isNull("dose_value")) null else intake.getDouble("dose_value")) &&
(if (current.isNull(4)) null else current.getString(4)) ==
(if (intake.isNull("dose_unit")) null else intake.getString("dose_unit")) &&
current.getString(5) == intake.getString("note") &&
current.getString(6) == intake.getString("created_at")
}
}
if (!sameEvents) return SleepDiaryImportResult.Rejected(
"sleep diary revision identity reused with different content",
)
@ -747,6 +909,17 @@ class TrainlogRepository(
event.getString("start_at"),
if (event.isNull("end_at")) null else event.getString("end_at")))
}
}, buildList {
val intakes = item.getJSONArray("intakes")
if (intakes.length() > 32) throw IllegalArgumentException("intake bound")
for (intakeIndex in 0 until intakes.length()) {
val intake = intakes.getJSONObject(intakeIndex)
val dose = if (intake.isNull("dose_value")) null else intake.getDouble("dose_value")
val unit = if (intake.isNull("dose_unit")) null else intake.getString("dose_unit")
add(MedicationIntake(intake.getString("intake_id"), intake.getString("medication_id"),
intake.getString("medication_name"), intake.getString("taken_at"), dose, unit,
intake.getString("note"), intake.getString("created_at")))
}
})
if (!sleepDraftValid(draft)) return SleepDiaryImportResult.Rejected("invalid entry")
if (local == null) db.execSQL("INSERT INTO sleep_diary_entries VALUES(?,?,?,?,?,?,?)",
@ -757,6 +930,12 @@ class TrainlogRepository(
arrayOf(revisionId,entryId,parent,draft.updatedAt,draft.sleepQuality?.wireValue,draft.wakeQuality?.wireValue,draft.dayForm?.wireValue,draft.treatmentAndNotes))
draft.events.forEach { event -> db.execSQL("INSERT INTO sleep_diary_events VALUES(?,?,?,?,?)",
arrayOf(revisionId,event.eventId,event.type.wireValue,event.startAt,event.endAt)) }
draft.intakes.forEach { intake -> db.execSQL(
"INSERT INTO sleep_medication_intakes VALUES(?,?,?,?,?,?,?,?,?)",
arrayOf<Any?>(revisionId, intake.intakeId, intake.medicationId,
intake.medicationName, intake.takenAt, intake.doseValue, intake.doseUnit,
intake.note, intake.createdAt),
) }
advanced++
}
SleepDiaryImportResult.Applied(advanced, unchanged)
@ -4435,7 +4614,7 @@ class TrainlogRepository(
).use { cursor -> if (!cursor.moveToFirst()) null else Pair(
if (cursor.isNull(0)) null else cursor.getString(0),
if (cursor.isNull(1)) null else cursor.getString(1),
) }
) }
if (existing != null) {
if (existing != Pair(parent, content) || current != revision) throw SyncLifecycleConflict(ownerId)
return
@ -9078,6 +9257,9 @@ private class TrainlogDatabaseHelper(
* atomically replace it by the shared root plus canonical pr2 tip. */
createExerciseProfileStateTable(db, seedLegacy = false)
normalizePrototypeExerciseProfileState(db)
/* Schema 26 is still unreleased. Early review installations may
* have opened it before medication capture joined Sleep Diary V1. */
createSleepDiaryTables(db)
db.setTransactionSuccessful()
} finally { db.endTransaction() }
}
@ -9421,6 +9603,27 @@ private class TrainlogDatabaseHelper(
)
db.execSQL("CREATE INDEX IF NOT EXISTS sleep_diary_entries_dates ON sleep_diary_entries(night_start_date,deleted)")
db.execSQL("CREATE INDEX IF NOT EXISTS sleep_diary_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,
current_revision_id TEXT NOT NULL, deleted INTEGER NOT NULL DEFAULT 0 CHECK(deleted IN(0,1)),
CHECK(medication_id GLOB 'med_*'))""".trimIndent())
db.execSQL("""CREATE TABLE IF NOT EXISTS sleep_medication_revisions(
revision_id TEXT PRIMARY KEY, medication_id TEXT NOT NULL REFERENCES sleep_medications(medication_id) ON DELETE RESTRICT,
parent_revision_id TEXT, created_at TEXT NOT NULL, name TEXT NOT NULL CHECK(length(trim(name)) BETWEEN 1 AND 160),
default_dose_value REAL CHECK(default_dose_value IS NULL OR default_dose_value>0), default_dose_unit TEXT,
form TEXT, note TEXT, active INTEGER NOT NULL CHECK(active IN(0,1)),
CHECK((default_dose_value IS NULL AND default_dose_unit IS NULL) OR
(default_dose_value IS NOT NULL AND length(trim(default_dose_unit)) BETWEEN 1 AND 32)))""".trimIndent())
db.execSQL("""CREATE TABLE IF NOT EXISTS sleep_medication_intakes(
revision_id TEXT NOT NULL REFERENCES sleep_diary_revisions(revision_id) ON DELETE RESTRICT,
intake_id TEXT NOT NULL, medication_id TEXT NOT NULL, medication_name TEXT NOT NULL,
taken_at TEXT NOT NULL, dose_value REAL, dose_unit TEXT, note TEXT, created_at TEXT NOT NULL,
PRIMARY KEY(revision_id,intake_id), CHECK(intake_id GLOB 'mdi_*'),
CHECK(length(trim(medication_name)) BETWEEN 1 AND 160),
CHECK((dose_value IS NULL AND dose_unit IS NULL) OR
(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)")
}
private fun createSyncDataLifecycleTables(db: SQLiteDatabase) {

View file

@ -7,15 +7,26 @@ enum class SleepEventType(val wireValue: String) {
DAYTIME_SLEEPINESS("daytime_sleepiness"),
}
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,
val name: String, val defaultDoseValue: Double?, val defaultDoseUnit: String?,
val form: String, val note: String, val active: Boolean,
)
data class MedicationIntake(
val intakeId: String, val medicationId: String, val medicationName: String,
val takenAt: String, val doseValue: Double?, val doseUnit: String?, val note: String,
val createdAt: String,
)
data class SleepDiaryEntry(
val entryId: String, val nightStartDate: String, val nightEndDate: String,
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 treatmentAndNotes: String, val events: List<SleepDiaryEvent>, val intakes: List<MedicationIntake>,
)
data class SleepDiaryDraft(
val entryId: String? = null, val expectedRevision: String? = null,
val nightStartDate: String, val nightEndDate: String, val createdAt: String,
val updatedAt: String, val sleepQuality: SleepQuality?, val wakeQuality: SleepQuality?,
val dayForm: SleepQuality?, val treatmentAndNotes: String, val events: List<SleepDiaryEvent>,
val intakes: List<MedicationIntake> = emptyList(),
)

View file

@ -29,6 +29,8 @@ 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.MedicationIntake
import com.labfytools.trainlog.model.SleepMedication
import java.time.LocalDate
import java.time.OffsetDateTime
@ -44,6 +46,16 @@ fun SleepDiaryScreen(repository: TrainlogRepository) {
var dayForm by remember(selected) { mutableStateOf(selected?.dayForm) }
var notes by remember(selected) { mutableStateOf(selected?.treatmentAndNotes.orEmpty()) }
var events by remember(selected) { mutableStateOf(selected?.events.orEmpty()) }
var intakes by remember(selected) { mutableStateOf(selected?.intakes.orEmpty()) }
val catalog = remember(revision) { repository.listSleepMedications() }
val medications = catalog.filter { it.active }
var intakeMedication by remember(medications) { mutableStateOf(medications.firstOrNull()) }
var intakeDose by remember(intakeMedication) { mutableStateOf(intakeMedication?.defaultDoseValue?.toString().orEmpty()) }
var intakeUnit by remember(intakeMedication) { mutableStateOf(intakeMedication?.defaultDoseUnit.orEmpty()) }
var medicationName by remember { mutableStateOf("") }
var medicationDose by remember { mutableStateOf("") }
var medicationUnit by remember { mutableStateOf("mg") }
var editingMedication by remember { mutableStateOf<SleepMedication?>(null) }
val entries = remember(revision) { repository.listSleepDiary() }
LazyColumn(
@ -100,11 +112,86 @@ fun SleepDiaryScreen(repository: TrainlogRepository) {
}
}
}
Text(stringResource(R.string.sleep_medications))
OutlinedTextField(medicationName, { medicationName = it },
label = { Text(stringResource(R.string.sleep_medications)) }, modifier = Modifier.fillMaxWidth())
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(medicationDose, { medicationDose = it },
label = { Text(stringResource(R.string.sleep_dose)) }, modifier = Modifier.weight(1f))
OutlinedTextField(medicationUnit, { medicationUnit = it },
label = { Text(stringResource(R.string.sleep_unit)) }, modifier = Modifier.weight(1f))
}
Button(onClick = {
val now = OffsetDateTime.now().toString()
val dose = medicationDose.toDoubleOrNull()
if (medicationName.isNotBlank() && (medicationDose.isEmpty() || dose != null && dose > 0.0)) {
val current = editingMedication
repository.saveSleepMedication(SleepMedication(current?.medicationId.orEmpty(),
current?.revisionId.orEmpty(), current?.createdAt ?: now, now,
medicationName.trim(), dose, if (dose == null) null else medicationUnit,
current?.form.orEmpty(), current?.note.orEmpty(), current?.active ?: true))
medicationName = ""; editingMedication = null; revision++
}
}) { Text(stringResource(R.string.sleep_add_medication)) }
medications.forEach { medication ->
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(selected = intakeMedication?.medicationId == medication.medicationId,
onClick = { intakeMedication = medication
intakeDose = medication.defaultDoseValue?.toString().orEmpty()
intakeUnit = medication.defaultDoseUnit.orEmpty() },
label = { Text(medication.name) })
Button(onClick = { editingMedication = medication; medicationName = medication.name
medicationDose = medication.defaultDoseValue?.toString().orEmpty()
medicationUnit = medication.defaultDoseUnit.orEmpty() }) {
Text(stringResource(R.string.sleep_edit))
}
Button(onClick = { repository.saveSleepMedication(medication.copy(
updatedAt = OffsetDateTime.now().toString(), active = false)); revision++ }) {
Text(stringResource(R.string.sleep_deactivate))
}
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(intakeDose, { intakeDose = it },
label = { Text(stringResource(R.string.sleep_dose)) }, modifier = Modifier.weight(1f))
OutlinedTextField(intakeUnit, { intakeUnit = it },
label = { Text(stringResource(R.string.sleep_unit)) }, modifier = Modifier.weight(1f))
}
Button(onClick = {
intakeMedication?.let { medication ->
val dose = intakeDose.toDoubleOrNull()
if (intakeDose.isEmpty() || dose != null && dose > 0.0) {
val now = OffsetDateTime.now().toString()
intakes = intakes + MedicationIntake("", medication.medicationId,
medication.name, now, dose, if (dose == null) null else intakeUnit,
"", now)
}
}
}) { Text(stringResource(R.string.sleep_add_intake)) }
intakes.forEachIndexed { index, intake -> Card {
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 } }
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 }
}, 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 }
}, label = { Text(stringResource(R.string.sleep_unit)) }, modifier = Modifier.weight(1f))
}
Button(onClick = { intakes = intakes.filterIndexed { item, _ -> item != index } }) {
Text(stringResource(R.string.sleep_delete))
}
}
} }
OutlinedTextField(notes, { notes = it }, 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))
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.deleteSleepDiary(entry.entryId,entry.revisionId,OffsetDateTime.now().toString()); selected = null; revision++ }) { Text(stringResource(R.string.sleep_delete)) } }

View file

@ -468,4 +468,11 @@
<string name="settings_backup_complete">Complete backup exported.</string>
<string name="settings_backup_failed">Backup failed; any private staged copy was retained.</string>
<string name="settings_restore_failed">The backup is invalid or could not be restored.</string>
<string name="sleep_medications">Medications</string>
<string name="sleep_add_medication">Add medication</string>
<string name="sleep_add_intake">Add intake</string>
<string name="sleep_dose">Dose</string>
<string name="sleep_unit">Unit</string>
<string name="sleep_deactivate">Deactivate</string>
<string name="sleep_edit">Edit</string>
</resources>

View file

@ -468,4 +468,11 @@
<string name="settings_backup_complete">Sauvegarde complète exportée.</string>
<string name="settings_backup_failed">Échec de la sauvegarde ; toute copie privée créée est conservée.</string>
<string name="settings_restore_failed">Sauvegarde invalide ou restauration impossible.</string>
<string name="sleep_medications">Médicaments</string>
<string name="sleep_add_medication">Ajouter un médicament</string>
<string name="sleep_add_intake">Ajouter une prise</string>
<string name="sleep_dose">Dosage</string>
<string name="sleep_unit">Unité</string>
<string name="sleep_deactivate">Désactiver</string>
<string name="sleep_edit">Modifier</string>
</resources>

View file

@ -6,6 +6,8 @@ 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.MedicationIntake
import com.labfytools.trainlog.model.SleepMedication
import java.util.UUID
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
@ -23,15 +25,28 @@ class SleepDiaryRepositoryTest {
val name = "sleep-${UUID.randomUUID()}.db"
var repository = TrainlogRepository(context, name)
try {
val catalogTime = "2026-10-24T18:00:00+02:00"
val medicationResult = repository.saveSleepMedication(SleepMedication("", "", catalogTime,
catalogTime, "Synthetic medication", 5.0, "mg", "tablet", "", true)) as
TrainlogRepository.SaveSleepDiaryResult.Saved
val initial = SleepDiaryDraft(nightStartDate = "2026-10-24", nightEndDate = "2026-10-25",
createdAt = "2026-10-24T23:30:00+02:00", updatedAt = "2026-10-24T23:30:00+02:00",
sleepQuality = SleepQuality.B, wakeQuality = null, dayForm = null,
treatmentAndNotes = "Synthetic fixture", events = listOf(
SleepDiaryEvent("", SleepEventType.SLEEP, "2026-10-24T23:30:00+02:00", "2026-10-25T06:30:00+01:00"),
SleepDiaryEvent("", SleepEventType.DAYTIME_SLEEPINESS, "2026-10-25T14:00:00+01:00", null)))
SleepDiaryEvent("", SleepEventType.DAYTIME_SLEEPINESS, "2026-10-25T14:00:00+01:00", null)),
intakes = listOf(MedicationIntake("", medicationResult.entryId,
"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(2, loaded.events.size)
assertEquals(10.0, loaded.intakes.single().doseValue!!, 0.0)
val medication = repository.listSleepMedications().single()
assertEquals(5.0, medication.defaultDoseValue!!, 0.0)
assertTrue(repository.saveSleepMedication(medication.copy(name = "Renamed medication",
updatedAt = "2026-10-25T12:00:00+01:00")) is
TrainlogRepository.SaveSleepDiaryResult.Saved)
assertEquals("Synthetic medication", loaded.intakes.single().medicationName)
val edited = repository.saveSleepDiary(initial.copy(entryId = created.entryId,
expectedRevision = created.revisionId, updatedAt = "2026-10-25T18:00:00+01:00",
wakeQuality = SleepQuality.MOY, dayForm = SleepQuality.TB))

View file

@ -3,6 +3,11 @@
## Sleep Diary V1
Android schema v26 additively mirrors the Sleep Diary revision/event model for
local-first capture. The unreleased schema-v26 contract also includes the
revisioned medication catalog and entry-revision-owned intake snapshots. A
catalog default dose is only a form default; each intake retains its actual
optional positive dose and unit. Existing development databases already at
v26 receive the missing additive tables during the normal open transaction.
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

View file

@ -9,6 +9,15 @@ one night and its associated day. Point events have an offset-bearing
their start. The 18:00-to-18:00 agenda is only a projection and is never stored
as cells.
The same unreleased migration owns `sleep_medications`, immutable
`sleep_medication_revisions`, and revision-owned `sleep_medication_intakes`.
Catalog defaults assist capture but do not own historical facts: every intake
stores the displayed medication name and effective optional positive dose/unit
snapshot. Renaming or deactivating a catalog item therefore cannot rewrite a
past entry. Development databases that had already opened the earlier v29
draft are completed idempotently on open; `user_version` remains 29 and no
existing row is rewritten.
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.

View file

@ -2,6 +2,13 @@
## Sleep Diary companion
`trainlog-sleep-diary` version 1 is finalized on the unreleased 0.1.4 review
branch with both the medication catalog and the entry-owned intake snapshots.
The artifact preserves catalog and diary causal parents, rejects concurrent
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.
`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

@ -19,7 +19,11 @@ def entry(revision, parent=None, deleted=False):
"sleep_quality": "B", "wake_quality": "Moy", "day_form": "TB",
"treatment_and_notes": "Synthetic fixture", "events": [
{"event_id": "sle_30000000-0000-4000-8000-000000000003", "type": "sleep",
"start_at": "2026-10-24T23:30:00+02:00", "end_at": "2026-10-25T06:30:00+01:00"}]}
"start_at": "2026-10-24T23:30:00+02:00", "end_at": "2026-10-25T06:30:00+01:00"}],
"intakes": [{"intake_id": "mdi_70000000-0000-4000-8000-000000000007",
"medication_id": "med_80000000-0000-4000-8000-000000000008",
"medication_name": "Synthetic medication", "taken_at": "2026-10-25T05:15:00+01:00",
"dose_value": 10, "dose_unit": "mg", "note": "", "created_at": "2026-10-24T22:00:00+02:00"}]}
class SleepExchangeTest(unittest.TestCase):
@ -29,10 +33,18 @@ class SleepExchangeTest(unittest.TestCase):
CREATE TABLE sleep_diary_entries(entry_id TEXT PRIMARY KEY,night_start_date TEXT,night_end_date TEXT,created_at TEXT,updated_at TEXT,current_revision_id TEXT,deleted INTEGER);
CREATE TABLE sleep_diary_revisions(revision_id TEXT PRIMARY KEY,entry_id TEXT,parent_revision_id TEXT,created_at TEXT,sleep_quality TEXT,wake_quality TEXT,day_form TEXT,treatment_and_notes TEXT);
CREATE TABLE sleep_diary_events(revision_id TEXT,event_id TEXT,event_type TEXT,start_at TEXT,end_at TEXT,PRIMARY KEY(revision_id,event_id));
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));
""")
def document(self, item):
return {"format": exchange.FORMAT, "version": 1, "generated_at": "2026-10-25T18:00:00+01:00", "entries": [item]}
medication = {"medication_id": "med_80000000-0000-4000-8000-000000000008",
"revision_id": "medr_90000000-0000-4000-8000-000000000009", "parent_revision_id": None,
"created_at": "2026-10-24T18:00:00+02:00", "updated_at": "2026-10-24T18:00:00+02:00",
"name": "Synthetic medication", "default_dose_value": 5, "default_dose_unit": "mg",
"form": "tablet", "note": "", "active": True, "deleted": False}
return {"format": exchange.FORMAT, "version": 1, "generated_at": "2026-10-25T18:00:00+01:00", "entries": [item], "medications": [medication]}
def test_roundtrip_replay_conflict_and_non_resurrection(self):
first = "slr_20000000-0000-4000-8000-000000000002"

View file

@ -16,10 +16,15 @@ VERSION = 1
MAX_BYTES = 16 * 1024 * 1024
MAX_ENTRIES = 3660
MAX_EVENTS = 64
MAX_INTAKES = 32
MAX_MEDICATIONS = 1000
UUID4 = r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}"
ENTRY_ID = re.compile(rf"^sl_{UUID4}$")
REVISION_ID = re.compile(rf"^slr_{UUID4}$")
EVENT_ID = re.compile(rf"^sle_{UUID4}$")
MEDICATION_ID = re.compile(rf"^med_{UUID4}$")
MEDICATION_REVISION_ID = re.compile(rf"^medr_{UUID4}$")
INTAKE_ID = re.compile(rf"^mdi_{UUID4}$")
POINTS = {"bed_time", "final_get_up", "night_get_up", "daytime_sleepiness"}
INTERVALS = {"sleep", "nap", "long_awake", "half_sleep"}
QUALITIES = {"TB", "B", "Moy", "M", "TM"}
@ -51,7 +56,7 @@ def timestamp(value: object) -> str:
def validate(root: object) -> dict:
if not isinstance(root, dict) or set(root) != {"format", "version", "generated_at", "entries"}:
if not isinstance(root, dict) or set(root) != {"format", "version", "generated_at", "entries", "medications"}:
fail("invalid sleep diary envelope")
if root["format"] != FORMAT or type(root["version"]) is not int or root["version"] != VERSION:
fail("unsupported sleep diary format")
@ -63,7 +68,7 @@ def validate(root: object) -> dict:
for item in entries:
keys = {"entry_id", "night_start_date", "night_end_date", "created_at", "updated_at",
"revision_id", "parent_revision_id", "deleted", "sleep_quality", "wake_quality",
"day_form", "treatment_and_notes", "events"}
"day_form", "treatment_and_notes", "events", "intakes"}
if not isinstance(item, dict) or set(item) != keys:
fail("invalid sleep diary entry")
if not ENTRY_ID.fullmatch(item["entry_id"]) or item["entry_id"] in seen_entries:
@ -111,6 +116,49 @@ def validate(root: object) -> dict:
fail("sleep interval is not positive in absolute time")
else:
fail("unknown sleep diary event type")
intakes = item["intakes"]
if not isinstance(intakes, list) or len(intakes) > MAX_INTAKES:
fail("medication intake bound exceeded")
seen_intakes = set()
for intake in intakes:
keys = {"intake_id", "medication_id", "medication_name", "taken_at",
"dose_value", "dose_unit", "note", "created_at"}
if not isinstance(intake, dict) or set(intake) != keys:
fail("invalid medication intake")
if (not INTAKE_ID.fullmatch(intake["intake_id"]) or
intake["intake_id"] in seen_intakes or
not MEDICATION_ID.fullmatch(intake["medication_id"])):
fail("invalid medication intake identity")
seen_intakes.add(intake["intake_id"])
timestamp(intake["taken_at"]); timestamp(intake["created_at"])
if not isinstance(intake["medication_name"], str) or not intake["medication_name"].strip():
fail("invalid medication snapshot name")
has_dose = intake["dose_value"] is not None
if has_dose != (intake["dose_unit"] is not None) or (has_dose and
(type(intake["dose_value"]) not in (int, float) or intake["dose_value"] <= 0 or
not isinstance(intake["dose_unit"], str) or not intake["dose_unit"].strip())):
fail("invalid medication intake dose")
medications = root["medications"]
if not isinstance(medications, list) or len(medications) > MAX_MEDICATIONS:
fail("medication catalog bound exceeded")
seen_medications = set()
for medication in medications:
keys = {"medication_id", "revision_id", "parent_revision_id", "created_at", "updated_at",
"name", "default_dose_value", "default_dose_unit", "form", "note", "active", "deleted"}
if not isinstance(medication, dict) or set(medication) != keys:
fail("invalid medication")
if (not MEDICATION_ID.fullmatch(medication["medication_id"]) or medication["medication_id"] in seen_medications or
not MEDICATION_REVISION_ID.fullmatch(medication["revision_id"])):
fail("invalid medication identity")
seen_medications.add(medication["medication_id"])
parent = medication["parent_revision_id"]
if parent is not None and not MEDICATION_REVISION_ID.fullmatch(parent): fail("invalid medication parent")
timestamp(medication["created_at"]); timestamp(medication["updated_at"])
if not isinstance(medication["name"], str) or not medication["name"].strip(): fail("invalid medication name")
has_dose = medication["default_dose_value"] is not None
if has_dose != (medication["default_dose_unit"] is not None) or (has_dose and
(type(medication["default_dose_value"]) not in (int, float) or medication["default_dose_value"] <= 0)):
fail("invalid default medication dose")
return root
@ -142,19 +190,46 @@ def build(db: sqlite3.Connection) -> dict:
"WHERE revision_id=? ORDER BY event_id", (revision_id,)
)
]
intakes = [{"intake_id": row[0], "medication_id": row[1], "medication_name": row[2],
"taken_at": row[3], "dose_value": row[4], "dose_unit": row[5],
"note": row[6], "created_at": row[7]}
for row in db.execute("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 intake_id", (revision_id,))]
entries.append({"entry_id": entry_id, "night_start_date": night_start,
"night_end_date": night_end, "created_at": created_at, "updated_at": updated_at,
"revision_id": revision_id, "parent_revision_id": revision[0], "deleted": bool(deleted),
"sleep_quality": revision[1], "wake_quality": revision[2], "day_form": revision[3],
"treatment_and_notes": revision[4] or "", "events": events})
"treatment_and_notes": revision[4] or "", "events": events, "intakes": intakes})
medications = []
for row in db.execute("SELECT m.medication_id,m.created_at,m.updated_at,m.current_revision_id,m.deleted,r.parent_revision_id,r.name,r.default_dose_value,r.default_dose_unit,r.form,r.note,r.active FROM sleep_medications m JOIN sleep_medication_revisions r ON r.revision_id=m.current_revision_id ORDER BY m.medication_id"):
medications.append({"medication_id": row[0], "created_at": row[1], "updated_at": row[2],
"revision_id": row[3], "deleted": bool(row[4]), "parent_revision_id": row[5],
"name": row[6], "default_dose_value": row[7], "default_dose_unit": row[8],
"form": row[9], "note": row[10], "active": bool(row[11])})
return validate({"format": FORMAT, "version": VERSION,
"generated_at": datetime.now().astimezone().isoformat(), "entries": entries})
"generated_at": datetime.now().astimezone().isoformat(), "entries": entries,
"medications": medications})
def apply(db: sqlite3.Connection, root: dict) -> tuple[int, int]:
validate(root)
applied = 0
unchanged = 0
for medication in root["medications"]:
local = db.execute("SELECT current_revision_id,deleted FROM sleep_medications WHERE medication_id=?", (medication["medication_id"],)).fetchone()
if local is not None and local[0] == medication["revision_id"]:
persisted = next(value for value in build(db)["medications"]
if value["medication_id"] == medication["medication_id"])
if persisted != medication:
fail("medication revision identity reused with different content")
continue
if local is not None and medication["parent_revision_id"] != local[0]: fail("concurrent medication revision")
if local is None and medication["parent_revision_id"] is not None: fail("unknown medication parent")
if local is not None and local[1] and not medication["deleted"]: fail("medication resurrection")
if local is None:
db.execute("INSERT INTO sleep_medications VALUES(?,?,?,?,?)", (medication["medication_id"], medication["created_at"], medication["updated_at"], medication["revision_id"], int(medication["deleted"])))
else:
db.execute("UPDATE sleep_medications SET updated_at=?,current_revision_id=?,deleted=? WHERE medication_id=? AND current_revision_id=?", (medication["updated_at"], medication["revision_id"], int(medication["deleted"]), medication["medication_id"], local[0]))
db.execute("INSERT INTO sleep_medication_revisions VALUES(?,?,?,?,?,?,?,?,?,?)", (medication["revision_id"], medication["medication_id"], medication["parent_revision_id"], medication["updated_at"], medication["name"], medication["default_dose_value"], medication["default_dose_unit"], medication["form"], medication["note"], int(medication["active"])))
for item in root["entries"]:
local = db.execute(
"SELECT current_revision_id,deleted FROM sleep_diary_entries WHERE entry_id=?",
@ -189,6 +264,11 @@ def apply(db: sqlite3.Connection, root: dict) -> tuple[int, int]:
db.executemany("INSERT INTO sleep_diary_events VALUES(?,?,?,?,?)",
[(item["revision_id"], event["event_id"], event["type"], event["start_at"], event["end_at"])
for event in item["events"]])
db.executemany("INSERT INTO sleep_medication_intakes VALUES(?,?,?,?,?,?,?,?,?)",
[(item["revision_id"], intake["intake_id"], intake["medication_id"],
intake["medication_name"], intake["taken_at"], intake["dose_value"],
intake["dose_unit"], intake["note"], intake["created_at"])
for intake in item["intakes"]])
applied += 1
return applied, unchanged

View file

@ -45,6 +45,7 @@
* - `pgr` for desktop Program V1 revision identities;
* - `del` for immutable causal deletion operations.
* - `sl`, `slr`, and `sle` for sleep diary entries, revisions, and events.
* - `med`, `medr`, and `mdi` for medications, medication revisions, and intakes.
*
* Imported v1 documents may contain other schema-valid opaque identifiers;
* this API defines creation policy, not import validation.

View file

@ -9,11 +9,44 @@
#include "trainlog/status.h"
#define TRAINLOG_SLEEP_EVENTS_MAX 64U
#define TRAINLOG_SLEEP_INTAKES_MAX 32U
#define TRAINLOG_SLEEP_NOTES_MAX 16384U
#define TRAINLOG_SLEEP_ENTRY_ID_CAPACITY 40U
#define TRAINLOG_SLEEP_REVISION_ID_CAPACITY 41U
#define TRAINLOG_SLEEP_EVENT_ID_CAPACITY 41U
#define TRAINLOG_SLEEP_TIMESTAMP_CAPACITY 64U
#define TRAINLOG_MEDICATION_NAME_CAPACITY 161U
#define TRAINLOG_MEDICATION_UNIT_CAPACITY 33U
#define TRAINLOG_MEDICATION_FORM_CAPACITY 65U
#define TRAINLOG_MEDICATION_NOTE_CAPACITY 513U
typedef struct TrainlogMedication {
char medication_id[41];
char revision_id[42];
char parent_revision_id[42];
char created_at[TRAINLOG_SLEEP_TIMESTAMP_CAPACITY];
char updated_at[TRAINLOG_SLEEP_TIMESTAMP_CAPACITY];
char name[TRAINLOG_MEDICATION_NAME_CAPACITY];
bool has_default_dose;
double default_dose_value;
char default_dose_unit[TRAINLOG_MEDICATION_UNIT_CAPACITY];
char form[TRAINLOG_MEDICATION_FORM_CAPACITY];
char note[TRAINLOG_MEDICATION_NOTE_CAPACITY];
bool active;
bool deleted;
} TrainlogMedication;
typedef struct TrainlogMedicationIntake {
char intake_id[42];
char medication_id[41];
char medication_name[TRAINLOG_MEDICATION_NAME_CAPACITY];
char taken_at[TRAINLOG_SLEEP_TIMESTAMP_CAPACITY];
bool has_dose;
double dose_value;
char dose_unit[TRAINLOG_MEDICATION_UNIT_CAPACITY];
char note[TRAINLOG_MEDICATION_NOTE_CAPACITY];
char created_at[TRAINLOG_SLEEP_TIMESTAMP_CAPACITY];
} TrainlogMedicationIntake;
typedef enum TrainlogSleepQuality {
TRAINLOG_SLEEP_QUALITY_UNSET = 0,
@ -56,6 +89,8 @@ typedef struct TrainlogSleepDiaryEntry {
char treatment_and_notes[TRAINLOG_SLEEP_NOTES_MAX + 1U];
TrainlogSleepEvent events[TRAINLOG_SLEEP_EVENTS_MAX];
size_t event_count;
TrainlogMedicationIntake intakes[TRAINLOG_SLEEP_INTAKES_MAX];
size_t intake_count;
bool deleted;
} TrainlogSleepDiaryEntry;
@ -97,4 +132,23 @@ trainlog_sleep_diary_delete(TrainlogDatabase *database,
const char *deleted_at,
char output_revision[TRAINLOG_SLEEP_REVISION_ID_CAPACITY]);
typedef TrainlogStatus (*TrainlogMedicationVisitor)(void *context,
const TrainlogMedication *medication);
bool trainlog_medication_validate(const TrainlogMedication *medication);
TrainlogStatus trainlog_medication_create(TrainlogDatabase *database,
TrainlogMedication *medication);
TrainlogStatus trainlog_medication_update(TrainlogDatabase *database,
const char *expected_revision,
TrainlogMedication *medication);
TrainlogStatus trainlog_medication_get(TrainlogDatabase *database,
const char *medication_id,
bool include_deleted,
TrainlogMedication *output);
TrainlogStatus trainlog_medication_list(TrainlogDatabase *database,
bool include_inactive,
size_t limit,
TrainlogMedicationVisitor visitor,
void *context);
#endif

View file

@ -37,4 +37,13 @@ TrainlogStatus trainlog_web_sleep_delete_request_json(TrainlogDatabase *database
char **output_json,
size_t *output_size);
TrainlogStatus trainlog_web_medication_list_json(TrainlogDatabase *database,
char **output_json,
size_t *output_size);
TrainlogStatus trainlog_web_medication_save_json(TrainlogDatabase *database,
const char *body,
size_t body_size,
char **output_json,
size_t *output_size);
#endif

View file

@ -1307,8 +1307,62 @@ static const char *const MIGRATE_V28_TO_V29_SQL =
"CREATE INDEX IF NOT EXISTS sleep_diary_entries_dates ON "
"sleep_diary_entries(night_start_date,deleted);"
"CREATE INDEX IF NOT EXISTS sleep_diary_events_time ON sleep_diary_events(start_at,end_at);"
"CREATE TABLE IF NOT EXISTS sleep_medications("
"medication_id TEXT PRIMARY KEY,created_at TEXT NOT NULL,updated_at TEXT NOT NULL,"
"current_revision_id TEXT NOT NULL,deleted INTEGER NOT NULL DEFAULT 0 CHECK(deleted IN(0,1)),"
"CHECK(medication_id GLOB 'med_*'));"
"CREATE TABLE IF NOT EXISTS sleep_medication_revisions("
"revision_id TEXT PRIMARY KEY,medication_id TEXT NOT NULL REFERENCES sleep_medications("
"medication_id) ON DELETE RESTRICT,parent_revision_id TEXT,created_at TEXT NOT NULL,"
"name TEXT NOT NULL CHECK(length(trim(name)) BETWEEN 1 AND 160),"
"default_dose_value REAL CHECK(default_dose_value IS NULL OR default_dose_value>0),"
"default_dose_unit TEXT,form TEXT,note TEXT,active INTEGER NOT NULL CHECK(active IN(0,1)),"
"CHECK((default_dose_value IS NULL AND default_dose_unit IS NULL) OR "
"(default_dose_value IS NOT NULL AND length(trim(default_dose_unit)) BETWEEN 1 AND 32)));"
"CREATE TABLE IF NOT EXISTS sleep_medication_intakes("
"revision_id TEXT NOT NULL REFERENCES sleep_diary_revisions(revision_id) ON DELETE RESTRICT,"
"intake_id TEXT NOT NULL,medication_id TEXT NOT NULL,medication_name TEXT NOT NULL,"
"taken_at TEXT NOT NULL,dose_value REAL,dose_unit TEXT,note TEXT,created_at TEXT NOT NULL,"
"PRIMARY KEY(revision_id,intake_id),CHECK(intake_id GLOB 'mdi_*'),"
"CHECK(length(trim(medication_name)) BETWEEN 1 AND 160),"
"CHECK((dose_value IS NULL AND dose_unit IS NULL) OR "
"(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);"
"PRAGMA user_version=29;COMMIT;";
/* WHY: schema v29 was already opened on the private 0.1.4 review installation
* before medication capture joined the same unreleased migration. CONTRACT:
* this additive repair is identical to the tail of v28 -> v29 and runs only
* while v29 remains the development schema. INVARIANT: existing diary rows
* are never rewritten and the transaction is safe to repeat on every open. */
static const char *const ENSURE_V29_SLEEP_MEDICATION_SQL =
"BEGIN IMMEDIATE;"
"CREATE TABLE IF NOT EXISTS sleep_medications("
"medication_id TEXT PRIMARY KEY,created_at TEXT NOT NULL,updated_at TEXT NOT NULL,"
"current_revision_id TEXT NOT NULL,deleted INTEGER NOT NULL DEFAULT 0 CHECK(deleted IN(0,1)),"
"CHECK(medication_id GLOB 'med_*'));"
"CREATE TABLE IF NOT EXISTS sleep_medication_revisions("
"revision_id TEXT PRIMARY KEY,medication_id TEXT NOT NULL REFERENCES sleep_medications("
"medication_id) ON DELETE RESTRICT,parent_revision_id TEXT,created_at TEXT NOT NULL,"
"name TEXT NOT NULL CHECK(length(trim(name)) BETWEEN 1 AND 160),"
"default_dose_value REAL CHECK(default_dose_value IS NULL OR default_dose_value>0),"
"default_dose_unit TEXT,form TEXT,note TEXT,active INTEGER NOT NULL CHECK(active IN(0,1)),"
"CHECK((default_dose_value IS NULL AND default_dose_unit IS NULL) OR "
"(default_dose_value IS NOT NULL AND length(trim(default_dose_unit)) BETWEEN 1 AND 32)));"
"CREATE TABLE IF NOT EXISTS sleep_medication_intakes("
"revision_id TEXT NOT NULL REFERENCES sleep_diary_revisions(revision_id) ON DELETE RESTRICT,"
"intake_id TEXT NOT NULL,medication_id TEXT NOT NULL,medication_name TEXT NOT NULL,"
"taken_at TEXT NOT NULL,dose_value REAL,dose_unit TEXT,note TEXT,created_at TEXT NOT NULL,"
"PRIMARY KEY(revision_id,intake_id),CHECK(intake_id GLOB 'mdi_*'),"
"CHECK(length(trim(medication_name)) BETWEEN 1 AND 160),"
"CHECK((dose_value IS NULL AND dose_unit IS NULL) OR "
"(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;";
/* CONTRACT: migration fixtures may retain additive v25 columns while
* deliberately lowering user_version. Add each provenance column only when
* absent; a genuine v24 database still receives both before opening returns. */
@ -2243,6 +2297,9 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
if (status == TRAINLOG_STATUS_OK && version < 29) {
status = execute_sql(database, MIGRATE_V28_TO_V29_SQL);
}
if (status == TRAINLOG_STATUS_OK) {
status = execute_sql(database, ENSURE_V29_SLEEP_MEDICATION_SQL);
}
if (status == TRAINLOG_STATUS_OK) {
status = ensure_v18_ai_draft_publication_state(database);
}

View file

@ -21,7 +21,8 @@ static bool prefix_is_supported(const char *prefix) {
strcmp(prefix, "sy") == 0 || strcmp(prefix, "sxe") == 0 || strcmp(prefix, "sp") == 0 ||
strcmp(prefix, "spr") == 0 || strcmp(prefix, "spe") == 0 || strcmp(prefix, "spd") == 0 ||
strcmp(prefix, "spw") == 0 || strcmp(prefix, "pgr") == 0 || strcmp(prefix, "del") == 0 ||
strcmp(prefix, "sl") == 0 || strcmp(prefix, "slr") == 0 || strcmp(prefix, "sle") == 0;
strcmp(prefix, "sl") == 0 || strcmp(prefix, "slr") == 0 || strcmp(prefix, "sle") == 0 ||
strcmp(prefix, "med") == 0 || strcmp(prefix, "medr") == 0 || strcmp(prefix, "mdi") == 0;
}
TrainlogStatus trainlog_id_generate(const char *prefix, char *output, size_t output_size) {

View file

@ -102,7 +102,8 @@ bool trainlog_sleep_diary_validate(const TrainlogSleepDiaryEntry *entry) {
entry->wake_quality > TRAINLOG_SLEEP_QUALITY_TM ||
entry->day_form > TRAINLOG_SLEEP_QUALITY_TM ||
strlen(entry->treatment_and_notes) > TRAINLOG_SLEEP_NOTES_MAX ||
entry->event_count > TRAINLOG_SLEEP_EVENTS_MAX) {
entry->event_count > TRAINLOG_SLEEP_EVENTS_MAX ||
entry->intake_count > TRAINLOG_SLEEP_INTAKES_MAX) {
return false;
}
for (index = 0U; index < entry->event_count; ++index) {
@ -122,6 +123,25 @@ bool trainlog_sleep_diary_validate(const TrainlogSleepDiaryEntry *entry) {
}
}
}
for (index = 0U; index < entry->intake_count; ++index) {
const TrainlogMedicationIntake *intake = &entry->intakes[index];
TrainlogTimestampKey taken, intake_created;
if (!id_valid(intake->intake_id, "mdi", sizeof(intake->intake_id)) ||
!id_valid(intake->medication_id, "med", sizeof(intake->medication_id)) ||
intake->medication_name[0] == '\0' ||
strlen(intake->medication_name) >= TRAINLOG_MEDICATION_NAME_CAPACITY ||
!timestamp_valid(intake->taken_at, &taken) ||
!timestamp_valid(intake->created_at, &intake_created) ||
(intake->has_dose && (!(intake->dose_value > 0.0) || intake->dose_unit[0] == '\0')) ||
(!intake->has_dose && (intake->dose_value != 0.0 || intake->dose_unit[0] != '\0'))) {
return false;
}
for (other = index + 1U; other < entry->intake_count; ++other) {
if (strcmp(intake->intake_id, entry->intakes[other].intake_id) == 0) {
return false;
}
}
}
return true;
}
@ -137,6 +157,10 @@ static TrainlogStatus insert_revision(TrainlogDatabase *database,
static const char event_sql[] =
"INSERT INTO sleep_diary_events(revision_id,event_id,event_type,start_at,end_at) "
"VALUES(?1,?2,?3,?4,?5)";
static const char intake_sql[] =
"INSERT INTO sleep_medication_intakes(revision_id,intake_id,medication_id,"
"medication_name,taken_at,dose_value,dose_unit,note,created_at) "
"VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9)";
sqlite3_stmt *statement = NULL;
size_t index;
int result = sqlite3_prepare_v2(database->connection, revision_sql, -1, &statement, NULL);
@ -220,6 +244,43 @@ static TrainlogStatus insert_revision(TrainlogDatabase *database,
: TRAINLOG_STATUS_DATABASE_ERROR;
}
}
for (index = 0U; index < entry->intake_count; ++index) {
const TrainlogMedicationIntake *intake = &entry->intakes[index];
statement = NULL;
result = sqlite3_prepare_v2(database->connection, intake_sql, -1, &statement, NULL);
#define BIND_INTAKE_TEXT(position, value) \
if (result == SQLITE_OK) { \
result = sqlite3_bind_text(statement, position, value, -1, SQLITE_TRANSIENT); \
}
BIND_INTAKE_TEXT(1, entry->revision_id);
BIND_INTAKE_TEXT(2, intake->intake_id);
BIND_INTAKE_TEXT(3, intake->medication_id);
BIND_INTAKE_TEXT(4, intake->medication_name);
BIND_INTAKE_TEXT(5, intake->taken_at);
if (result == SQLITE_OK) {
result = intake->has_dose ? sqlite3_bind_double(statement, 6, intake->dose_value)
: sqlite3_bind_null(statement, 6);
}
if (result == SQLITE_OK) {
result = intake->has_dose
? sqlite3_bind_text(statement, 7, intake->dose_unit, -1, SQLITE_TRANSIENT)
: sqlite3_bind_null(statement, 7);
}
BIND_INTAKE_TEXT(8, intake->note);
BIND_INTAKE_TEXT(9, intake->created_at);
#undef BIND_INTAKE_TEXT
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 result == SQLITE_CONSTRAINT ? TRAINLOG_STATUS_CONFLICT
: TRAINLOG_STATUS_DATABASE_ERROR;
}
}
return TRAINLOG_STATUS_OK;
}
@ -363,6 +424,10 @@ static TrainlogStatus load_entry(TrainlogDatabase *database,
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";
static const char intakes_sql[] =
"SELECT intake_id,medication_id,medication_name,taken_at,dose_value,dose_unit,note,"
"created_at FROM sleep_medication_intakes WHERE revision_id=?1 "
"ORDER BY taken_at COLLATE BINARY,intake_id COLLATE BINARY";
sqlite3_stmt *statement = NULL;
int result;
memset(output, 0, sizeof(*output));
@ -443,6 +508,46 @@ static TrainlogStatus load_entry(TrainlogDatabase *database,
if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK && result == SQLITE_DONE) {
result = SQLITE_ERROR;
}
if (result != SQLITE_DONE) {
return TRAINLOG_STATUS_DATABASE_ERROR;
}
statement = NULL;
result = sqlite3_prepare_v2(database->connection, intakes_sql, -1, &statement, NULL);
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 1, output->revision_id, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_step(statement);
}
while (result == SQLITE_ROW) {
TrainlogMedicationIntake *intake;
if (output->intake_count == TRAINLOG_SLEEP_INTAKES_MAX) {
result = SQLITE_TOOBIG;
break;
}
intake = &output->intakes[output->intake_count++];
#define COPY_INTAKE(field, column) \
(void)snprintf(intake->field, \
sizeof(intake->field), \
"%s", \
sqlite3_column_text(statement, column) == NULL \
? "" \
: (const char *)sqlite3_column_text(statement, column))
COPY_INTAKE(intake_id, 0);
COPY_INTAKE(medication_id, 1);
COPY_INTAKE(medication_name, 2);
COPY_INTAKE(taken_at, 3);
intake->has_dose = sqlite3_column_type(statement, 4) != SQLITE_NULL;
intake->dose_value = intake->has_dose ? sqlite3_column_double(statement, 4) : 0.0;
COPY_INTAKE(dose_unit, 5);
COPY_INTAKE(note, 6);
COPY_INTAKE(created_at, 7);
#undef COPY_INTAKE
result = sqlite3_step(statement);
}
if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK && result == SQLITE_DONE) {
result = SQLITE_ERROR;
}
return result == SQLITE_DONE && trainlog_sleep_diary_validate(output)
? TRAINLOG_STATUS_OK
: TRAINLOG_STATUS_DATABASE_ERROR;
@ -584,3 +689,289 @@ trainlog_sleep_diary_delete(TrainlogDatabase *database,
rollback(database->connection);
return status == TRAINLOG_STATUS_OK ? TRAINLOG_STATUS_DATABASE_ERROR : status;
}
bool trainlog_medication_validate(const TrainlogMedication *medication) {
TrainlogTimestampKey created, updated;
return medication != NULL && medication->name[0] != '\0' &&
strlen(medication->name) < TRAINLOG_MEDICATION_NAME_CAPACITY &&
timestamp_valid(medication->created_at, &created) &&
timestamp_valid(medication->updated_at, &updated) &&
trainlog_timestamp_compare(&updated, &created) >= 0 &&
((!medication->has_default_dose && medication->default_dose_value == 0.0 &&
medication->default_dose_unit[0] == '\0') ||
(medication->has_default_dose && medication->default_dose_value > 0.0 &&
medication->default_dose_unit[0] != '\0'));
}
static TrainlogStatus insert_medication_revision(TrainlogDatabase *database,
const TrainlogMedication *medication) {
static const char sql[] =
"INSERT INTO sleep_medication_revisions(revision_id,medication_id,parent_revision_id,"
"created_at,name,default_dose_value,default_dose_unit,form,note,active) "
"VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)";
sqlite3_stmt *statement = NULL;
int result = sqlite3_prepare_v2(database->connection, sql, -1, &statement, NULL);
#define BIND_MED_TEXT(position, value) \
if (result == SQLITE_OK) { \
result = sqlite3_bind_text(statement, position, value, -1, SQLITE_TRANSIENT); \
}
BIND_MED_TEXT(1, medication->revision_id);
BIND_MED_TEXT(2, medication->medication_id);
if (result == SQLITE_OK) {
result = medication->parent_revision_id[0] == '\0'
? sqlite3_bind_null(statement, 3)
: sqlite3_bind_text(
statement, 3, medication->parent_revision_id, -1, SQLITE_TRANSIENT);
}
BIND_MED_TEXT(4, medication->updated_at);
BIND_MED_TEXT(5, medication->name);
if (result == SQLITE_OK) {
result = medication->has_default_dose
? sqlite3_bind_double(statement, 6, medication->default_dose_value)
: sqlite3_bind_null(statement, 6);
}
if (result == SQLITE_OK) {
result = medication->has_default_dose
? sqlite3_bind_text(
statement, 7, medication->default_dose_unit, -1, SQLITE_TRANSIENT)
: sqlite3_bind_null(statement, 7);
}
BIND_MED_TEXT(8, medication->form);
BIND_MED_TEXT(9, medication->note);
#undef BIND_MED_TEXT
if (result == SQLITE_OK) {
result = sqlite3_bind_int(statement, 10, medication->active ? 1 : 0);
}
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;
}
return result == SQLITE_OK ? TRAINLOG_STATUS_OK
: result == SQLITE_CONSTRAINT ? TRAINLOG_STATUS_CONFLICT
: TRAINLOG_STATUS_DATABASE_ERROR;
}
TrainlogStatus trainlog_medication_create(TrainlogDatabase *database,
TrainlogMedication *medication) {
static const char sql[] =
"INSERT INTO sleep_medications(medication_id,created_at,updated_at,current_revision_id,"
"deleted) VALUES(?1,?2,?3,?4,0)";
sqlite3_stmt *statement = NULL;
TrainlogStatus status;
int result;
if (database == NULL || medication == NULL || medication->medication_id[0] != '\0' ||
medication->revision_id[0] != '\0' || medication->deleted ||
trainlog_id_generate("med", medication->medication_id, sizeof(medication->medication_id)) !=
TRAINLOG_STATUS_OK ||
trainlog_id_generate("medr", medication->revision_id, sizeof(medication->revision_id)) !=
TRAINLOG_STATUS_OK ||
!trainlog_medication_validate(medication)) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
if (sqlite3_exec(database->connection, "BEGIN IMMEDIATE", NULL, NULL, NULL) != SQLITE_OK) {
return TRAINLOG_STATUS_DATABASE_ERROR;
}
result = sqlite3_prepare_v2(database->connection, sql, -1, &statement, NULL);
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 1, medication->medication_id, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 2, medication->created_at, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 3, medication->updated_at, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 4, medication->revision_id, -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;
}
status = result == SQLITE_OK ? insert_medication_revision(database, medication)
: (result == SQLITE_CONSTRAINT ? TRAINLOG_STATUS_CONFLICT
: TRAINLOG_STATUS_DATABASE_ERROR);
if (status == TRAINLOG_STATUS_OK &&
sqlite3_exec(database->connection, "COMMIT", NULL, NULL, NULL) == SQLITE_OK) {
return status;
}
rollback(database->connection);
return status == TRAINLOG_STATUS_OK ? TRAINLOG_STATUS_DATABASE_ERROR : status;
}
static TrainlogStatus load_medication(TrainlogDatabase *database,
const char *medication_id,
bool include_deleted,
TrainlogMedication *output) {
static const char sql[] =
"SELECT m.medication_id,m.created_at,m.updated_at,m.current_revision_id,m.deleted,"
"r.parent_revision_id,r.name,r.default_dose_value,r.default_dose_unit,r.form,r.note,"
"r.active FROM sleep_medications m JOIN sleep_medication_revisions r ON "
"r.revision_id=m.current_revision_id WHERE m.medication_id=?1 AND (?2 OR m.deleted=0)";
sqlite3_stmt *statement = NULL;
int result = sqlite3_prepare_v2(database->connection, sql, -1, &statement, NULL);
memset(output, 0, sizeof(*output));
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 1, medication_id, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_int(statement, 2, include_deleted ? 1 : 0);
}
if (result != SQLITE_OK || sqlite3_step(statement) != SQLITE_ROW) {
if (statement != NULL) {
(void)sqlite3_finalize(statement);
}
return result == SQLITE_OK ? TRAINLOG_STATUS_NOT_FOUND : TRAINLOG_STATUS_DATABASE_ERROR;
}
#define COPY_MED(field, column) \
(void)snprintf(output->field, \
sizeof(output->field), \
"%s", \
sqlite3_column_text(statement, column) == NULL \
? "" \
: (const char *)sqlite3_column_text(statement, column))
COPY_MED(medication_id, 0);
COPY_MED(created_at, 1);
COPY_MED(updated_at, 2);
COPY_MED(revision_id, 3);
output->deleted = sqlite3_column_int(statement, 4) != 0;
COPY_MED(parent_revision_id, 5);
COPY_MED(name, 6);
output->has_default_dose = sqlite3_column_type(statement, 7) != SQLITE_NULL;
output->default_dose_value =
output->has_default_dose ? sqlite3_column_double(statement, 7) : 0.0;
COPY_MED(default_dose_unit, 8);
COPY_MED(form, 9);
COPY_MED(note, 10);
output->active = sqlite3_column_int(statement, 11) != 0;
#undef COPY_MED
result = sqlite3_finalize(statement);
return result == SQLITE_OK && trainlog_medication_validate(output)
? TRAINLOG_STATUS_OK
: TRAINLOG_STATUS_DATABASE_ERROR;
}
TrainlogStatus trainlog_medication_get(TrainlogDatabase *database,
const char *medication_id,
bool include_deleted,
TrainlogMedication *output) {
if (database == NULL || output == NULL ||
!id_valid(medication_id, "med", sizeof(output->medication_id))) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
return load_medication(database, medication_id, include_deleted, output);
}
TrainlogStatus trainlog_medication_update(TrainlogDatabase *database,
const char *expected_revision,
TrainlogMedication *medication) {
static const char sql[] =
"UPDATE sleep_medications SET updated_at=?1,current_revision_id=?2 WHERE "
"medication_id=?3 AND current_revision_id=?4 AND deleted=0";
sqlite3_stmt *statement = NULL;
TrainlogStatus status;
char next[sizeof(medication->revision_id)];
char expected[sizeof(medication->revision_id)];
int result;
if (database == NULL || medication == NULL || medication->deleted ||
!id_valid(medication->medication_id, "med", sizeof(medication->medication_id)) ||
!id_valid(expected_revision, "medr", sizeof(medication->revision_id)) ||
strcmp(medication->revision_id, expected_revision) != 0 ||
trainlog_id_generate("medr", next, sizeof(next)) != TRAINLOG_STATUS_OK) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
(void)snprintf(expected, sizeof(expected), "%s", expected_revision);
(void)snprintf(
medication->parent_revision_id, sizeof(medication->parent_revision_id), "%s", expected);
(void)snprintf(medication->revision_id, sizeof(medication->revision_id), "%s", next);
if (!trainlog_medication_validate(medication)) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
if (sqlite3_exec(database->connection, "BEGIN IMMEDIATE", NULL, NULL, NULL) != SQLITE_OK) {
return TRAINLOG_STATUS_DATABASE_ERROR;
}
status = insert_medication_revision(database, medication);
result = status == TRAINLOG_STATUS_OK
? sqlite3_prepare_v2(database->connection, sql, -1, &statement, NULL)
: SQLITE_ERROR;
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 1, medication->updated_at, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 2, medication->revision_id, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 3, medication->medication_id, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 4, expected, -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 (status == TRAINLOG_STATUS_OK) {
status = result != SQLITE_OK
? TRAINLOG_STATUS_DATABASE_ERROR
: (sqlite3_changes(database->connection) == 1 ? TRAINLOG_STATUS_OK
: TRAINLOG_STATUS_CONFLICT);
}
if (status == TRAINLOG_STATUS_OK &&
sqlite3_exec(database->connection, "COMMIT", NULL, NULL, NULL) == SQLITE_OK) {
return status;
}
rollback(database->connection);
return status == TRAINLOG_STATUS_OK ? TRAINLOG_STATUS_DATABASE_ERROR : status;
}
TrainlogStatus trainlog_medication_list(TrainlogDatabase *database,
bool include_inactive,
size_t limit,
TrainlogMedicationVisitor visitor,
void *context) {
static const char sql[] =
"SELECT m.medication_id FROM sleep_medications m JOIN sleep_medication_revisions r ON "
"r.revision_id=m.current_revision_id WHERE m.deleted=0 AND (?1 OR r.active=1) "
"ORDER BY r.name COLLATE NOCASE,m.medication_id COLLATE BINARY LIMIT ?2";
sqlite3_stmt *statement = NULL;
int result;
if (database == NULL || visitor == NULL || limit == 0U || limit > 1000U) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
result = sqlite3_prepare_v2(database->connection, sql, -1, &statement, NULL);
if (result == SQLITE_OK) {
result = sqlite3_bind_int(statement, 1, include_inactive ? 1 : 0);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_int64(statement, 2, (sqlite3_int64)limit);
}
if (result == SQLITE_OK) {
result = sqlite3_step(statement);
}
while (result == SQLITE_ROW) {
TrainlogMedication medication;
TrainlogStatus status = load_medication(
database, (const char *)sqlite3_column_text(statement, 0), false, &medication);
if (status == TRAINLOG_STATUS_OK) {
status = visitor(context, &medication);
}
if (status != TRAINLOG_STATUS_OK) {
(void)sqlite3_finalize(statement);
return status;
}
result = sqlite3_step(statement);
}
if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK && result == SQLITE_DONE) {
result = SQLITE_ERROR;
}
return result == SQLITE_DONE ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_DATABASE_ERROR;
}

View file

@ -1471,6 +1471,49 @@ static enum MHD_Result handle_request(void *closure,
free(json);
return queued;
}
if (strcmp(url, "/api/v1/sleep-medications") == 0) {
char *json = NULL;
size_t json_size = 0U;
TrainlogStatus status;
enum MHD_Result queued;
if (is_get) {
status = trainlog_web_medication_list_json(context->database, &json, &json_size);
} else if (strcmp(method, MHD_HTTP_METHOD_POST) == 0) {
if (!program_mutation_allowed(context, connection)) {
return queue_json(
connection, MHD_HTTP_FORBIDDEN, "{\"error\":\"mutation_forbidden\"}\n", NULL);
}
status = trainlog_web_medication_save_json(
context->database, state->body, state->body_size, &json, &json_size);
} else {
return queue_json(connection,
MHD_HTTP_METHOD_NOT_ALLOWED,
"{\"error\":\"method_not_allowed\"}\n",
"GET, POST");
}
if (status == TRAINLOG_STATUS_CONFLICT) {
free(json);
return queue_json(
connection, MHD_HTTP_CONFLICT, "{\"error\":\"revision_conflict\"}\n", NULL);
}
if (status == TRAINLOG_STATUS_INVALID_ARGUMENT) {
free(json);
return queue_json(connection,
MHD_HTTP_UNPROCESSABLE_CONTENT,
"{\"error\":\"invalid_sleep_medication\"}\n",
NULL);
}
if (status != TRAINLOG_STATUS_OK || json == NULL || json_size == 0U) {
free(json);
return queue_json(connection,
MHD_HTTP_INTERNAL_SERVER_ERROR,
"{\"error\":\"sleep_medications_unavailable\"}\n",
NULL);
}
queued = queue_json(connection, MHD_HTTP_OK, json, NULL);
free(json);
return queued;
}
if (strcmp(url, "/api/v1/dashboard") == 0) {
TrainlogWebDashboardQuery query;
TrainlogWebDashboardSnapshot snapshot;

View file

@ -20,6 +20,7 @@ typedef struct SleepListContext {
size_t long_awake_count;
size_t nap_count;
size_t sleepiness_count;
size_t intake_count;
size_t quality[3][5];
int64_t bed_minute_total;
int64_t get_up_minute_total;
@ -121,8 +122,9 @@ static bool
add_entry(yyjson_mut_doc *document, yyjson_mut_val *items, const TrainlogSleepDiaryEntry *entry) {
yyjson_mut_val *object = yyjson_mut_obj(document);
yyjson_mut_val *events = yyjson_mut_arr(document);
yyjson_mut_val *intakes = yyjson_mut_arr(document);
size_t index;
if (object == NULL || events == NULL ||
if (object == NULL || events == NULL || intakes == NULL ||
!yyjson_mut_obj_add_strcpy(document, object, "entry_id", entry->entry_id) ||
!yyjson_mut_obj_add_strcpy(document, object, "night_start_date", entry->night_start_date) ||
!yyjson_mut_obj_add_strcpy(document, object, "night_end_date", entry->night_end_date) ||
@ -150,7 +152,29 @@ add_entry(yyjson_mut_doc *document, yyjson_mut_val *items, const TrainlogSleepDi
return false;
}
}
for (index = 0U; index < entry->intake_count; ++index) {
const TrainlogMedicationIntake *intake = &entry->intakes[index];
yyjson_mut_val *item = yyjson_mut_obj(document);
if (item == NULL ||
!yyjson_mut_obj_add_strcpy(document, item, "intake_id", intake->intake_id) ||
!yyjson_mut_obj_add_strcpy(document, item, "medication_id", intake->medication_id) ||
!yyjson_mut_obj_add_strcpy(
document, item, "medication_name", intake->medication_name) ||
!yyjson_mut_obj_add_strcpy(document, item, "taken_at", intake->taken_at) ||
!(intake->has_dose
? yyjson_mut_obj_add_real(document, item, "dose_value", intake->dose_value)
: yyjson_mut_obj_add_null(document, item, "dose_value")) ||
!(intake->has_dose
? yyjson_mut_obj_add_strcpy(document, item, "dose_unit", intake->dose_unit)
: yyjson_mut_obj_add_null(document, item, "dose_unit")) ||
!yyjson_mut_obj_add_strcpy(document, item, "note", intake->note) ||
!yyjson_mut_obj_add_strcpy(document, item, "created_at", intake->created_at) ||
!yyjson_mut_arr_add_val(intakes, item)) {
return false;
}
}
return yyjson_mut_obj_add_val(document, object, "events", events) &&
yyjson_mut_obj_add_val(document, object, "intakes", intakes) &&
yyjson_mut_arr_add_val(items, object);
}
@ -161,6 +185,7 @@ static TrainlogStatus list_visitor(void *opaque, const TrainlogSleepDiaryEntry *
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
++context->count;
context->intake_count += entry->intake_count;
if (entry->sleep_quality != TRAINLOG_SLEEP_QUALITY_UNSET) {
++context->quality[0][entry->sleep_quality - 1];
}
@ -260,6 +285,7 @@ TrainlogStatus trainlog_web_sleep_list_json(TrainlogDatabase *database,
!yyjson_mut_obj_add_uint(document, summary, "long_awake_count", context.long_awake_count) ||
!yyjson_mut_obj_add_uint(document, summary, "nap_count", context.nap_count) ||
!yyjson_mut_obj_add_uint(document, summary, "sleepiness_count", context.sleepiness_count) ||
!yyjson_mut_obj_add_uint(document, summary, "intake_count", context.intake_count) ||
!yyjson_mut_obj_add_sint(
document, summary, "sleep_duration_seconds", context.sleep_seconds) ||
!yyjson_mut_obj_add_sint(
@ -339,6 +365,7 @@ static bool parse_entry(const char *body,
yyjson_read_opts((char *)body, body_size, YYJSON_READ_NOFLAG, NULL, &error);
yyjson_val *root = document == NULL ? NULL : yyjson_doc_get_root(document);
yyjson_val *events;
yyjson_val *intakes;
size_t index, count;
bool valid =
root != NULL && yyjson_is_obj(root) &&
@ -384,6 +411,45 @@ static bool parse_entry(const char *body,
}
}
entry->event_count = count;
intakes = valid ? yyjson_obj_get(root, "intakes") : NULL;
count = yyjson_is_arr(intakes) ? yyjson_arr_size(intakes) : TRAINLOG_SLEEP_INTAKES_MAX + 1U;
if (count > TRAINLOG_SLEEP_INTAKES_MAX) {
valid = false;
}
for (index = 0U; valid && index < count; ++index) {
yyjson_val *value = yyjson_arr_get(intakes, index);
yyjson_val *dose_value = yyjson_obj_get(value, "dose_value");
yyjson_val *dose_unit = yyjson_obj_get(value, "dose_unit");
TrainlogMedicationIntake *intake = &entry->intakes[index];
valid =
yyjson_is_obj(value) &&
copy_string(value, "intake_id", intake->intake_id, sizeof(intake->intake_id), true) &&
copy_string(value,
"medication_id",
intake->medication_id,
sizeof(intake->medication_id),
false) &&
copy_string(value,
"medication_name",
intake->medication_name,
sizeof(intake->medication_name),
false) &&
copy_string(value, "taken_at", intake->taken_at, sizeof(intake->taken_at), false) &&
copy_string(value, "note", intake->note, sizeof(intake->note), false) &&
copy_string(
value, "created_at", intake->created_at, sizeof(intake->created_at), false) &&
((yyjson_is_null(dose_value) && yyjson_is_null(dose_unit)) ||
(yyjson_is_num(dose_value) && yyjson_get_real(dose_value) > 0.0 &&
copy_string(
value, "dose_unit", intake->dose_unit, sizeof(intake->dose_unit), false)));
intake->has_dose = yyjson_is_num(dose_value);
intake->dose_value = intake->has_dose ? yyjson_get_real(dose_value) : 0.0;
if (valid && intake->intake_id[0] == '\0') {
valid = trainlog_id_generate("mdi", intake->intake_id, sizeof(intake->intake_id)) ==
TRAINLOG_STATUS_OK;
}
}
entry->intake_count = count;
yyjson_doc_free(document);
return valid;
}
@ -486,3 +552,134 @@ TrainlogStatus trainlog_web_sleep_delete_request_json(TrainlogDatabase *database
yyjson_doc_free(document);
return status;
}
typedef struct MedicationListContext {
yyjson_mut_doc *document;
yyjson_mut_val *items;
} MedicationListContext;
static TrainlogStatus medication_list_visitor(void *opaque, const TrainlogMedication *medication) {
MedicationListContext *context = opaque;
yyjson_mut_val *item = yyjson_mut_obj(context->document);
if (item == NULL ||
!yyjson_mut_obj_add_strcpy(
context->document, item, "medication_id", medication->medication_id) ||
!yyjson_mut_obj_add_strcpy(
context->document, item, "revision_id", medication->revision_id) ||
!yyjson_mut_obj_add_strcpy(context->document, item, "name", medication->name) ||
!(medication->has_default_dose
? yyjson_mut_obj_add_real(
context->document, item, "default_dose_value", medication->default_dose_value)
: yyjson_mut_obj_add_null(context->document, item, "default_dose_value")) ||
!(medication->has_default_dose
? yyjson_mut_obj_add_strcpy(
context->document, item, "default_dose_unit", medication->default_dose_unit)
: yyjson_mut_obj_add_null(context->document, item, "default_dose_unit")) ||
!yyjson_mut_obj_add_strcpy(context->document, item, "form", medication->form) ||
!yyjson_mut_obj_add_strcpy(context->document, item, "note", medication->note) ||
!yyjson_mut_obj_add_bool(context->document, item, "active", medication->active) ||
!yyjson_mut_arr_add_val(context->items, item)) {
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
return TRAINLOG_STATUS_OK;
}
TrainlogStatus trainlog_web_medication_list_json(TrainlogDatabase *database,
char **output_json,
size_t *output_size) {
yyjson_mut_doc *document = yyjson_mut_doc_new(NULL);
yyjson_mut_val *root = document == NULL ? NULL : yyjson_mut_obj(document);
MedicationListContext context = {document, document == NULL ? NULL : yyjson_mut_arr(document)};
TrainlogStatus status;
if (database == NULL || output_json == NULL || output_size == NULL || root == NULL ||
context.items == NULL) {
yyjson_mut_doc_free(document);
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
yyjson_mut_doc_set_root(document, root);
status = trainlog_medication_list(database, true, 1000U, medication_list_visitor, &context);
if (status != TRAINLOG_STATUS_OK ||
!yyjson_mut_obj_add_uint(document, root, "api_version", 1U) ||
!yyjson_mut_obj_add_val(document, root, "medications", context.items)) {
yyjson_mut_doc_free(document);
return status == TRAINLOG_STATUS_OK ? TRAINLOG_STATUS_SYSTEM_ERROR : status;
}
return write_document(document, output_json, output_size);
}
TrainlogStatus trainlog_web_medication_save_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;
yyjson_val *dose;
yyjson_val *active;
TrainlogMedication medication = {0};
char expected[sizeof(medication.revision_id)] = "";
TrainlogStatus status;
yyjson_mut_doc *document;
yyjson_mut_val *result;
if (database == NULL || body == NULL || body_size == 0U ||
body_size > TRAINLOG_WEB_SLEEP_BYTES_MAX) {
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);
dose = yyjson_is_obj(root) ? yyjson_obj_get(root, "default_dose_value") : NULL;
active = yyjson_is_obj(root) ? yyjson_obj_get(root, "active") : NULL;
if (!yyjson_is_obj(root) ||
!copy_string(root,
"medication_id",
medication.medication_id,
sizeof(medication.medication_id),
true) ||
!copy_string(root, "expected_revision", expected, sizeof(expected), true) ||
!copy_string(
root, "created_at", medication.created_at, sizeof(medication.created_at), false) ||
!copy_string(
root, "updated_at", medication.updated_at, sizeof(medication.updated_at), false) ||
!copy_string(root, "name", medication.name, sizeof(medication.name), false) ||
!copy_string(root, "form", medication.form, sizeof(medication.form), false) ||
!copy_string(root, "note", medication.note, sizeof(medication.note), false) ||
!yyjson_is_bool(active) ||
!((yyjson_is_null(dose) && copy_string(root,
"default_dose_unit",
medication.default_dose_unit,
sizeof(medication.default_dose_unit),
true)) ||
(yyjson_is_num(dose) && yyjson_get_real(dose) > 0.0 &&
copy_string(root,
"default_dose_unit",
medication.default_dose_unit,
sizeof(medication.default_dose_unit),
false)))) {
yyjson_doc_free(input);
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
medication.has_default_dose = yyjson_is_num(dose);
medication.default_dose_value = medication.has_default_dose ? yyjson_get_real(dose) : 0.0;
medication.active = yyjson_get_bool(active);
if (expected[0] == '\0') {
status = trainlog_medication_create(database, &medication);
} else {
(void)snprintf(medication.revision_id, sizeof(medication.revision_id), "%s", expected);
status = trainlog_medication_update(database, expected, &medication);
}
yyjson_doc_free(input);
if (status != TRAINLOG_STATUS_OK) {
return status;
}
document = yyjson_mut_doc_new(NULL);
result = document == NULL ? NULL : yyjson_mut_obj(document);
if (result == NULL ||
!yyjson_mut_obj_add_strcpy(document, result, "medication_id", medication.medication_id) ||
!yyjson_mut_obj_add_strcpy(document, result, "revision_id", medication.revision_id)) {
yyjson_mut_doc_free(document);
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
yyjson_mut_doc_set_root(document, result);
return write_document(document, output_json, output_size);
}

View file

@ -51,12 +51,38 @@ int main(void) {
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_medications','sleep_medication_revisions',"
"'sleep_medication_intakes')") == 3);
CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_foreign_key_check") == 0);
CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_integrity_check WHERE integrity_check='ok'") ==
1);
CHECK(sqlite3_close(raw) == SQLITE_OK);
raw = NULL;
CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK);
trainlog_database_close(production);
production = NULL;
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(sqlite3_exec(raw,
"DROP TABLE sleep_medication_intakes;"
"DROP TABLE sleep_medication_revisions;"
"DROP TABLE sleep_medications;",
NULL,
NULL,
NULL) == SQLITE_OK);
CHECK(sqlite3_close(raw) == SQLITE_OK);
raw = NULL;
/* An unpublished v29 may already exist locally. Opening it completes the
* same v29 contract without rewriting user data or changing user_version. */
CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK);
trainlog_database_close(production);
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 type='table' AND name LIKE "
"'sleep_medication%'") == 3);
result = EXIT_SUCCESS;
cleanup:
if (production != NULL) {

View file

@ -58,6 +58,16 @@ static TrainlogStatus count_visitor(void *context, const TrainlogSleepDiaryEntry
return TRAINLOG_STATUS_OK;
}
static TrainlogStatus medication_count_visitor(void *context,
const TrainlogMedication *medication) {
size_t *count = context;
if (medication == NULL) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
++*count;
return TRAINLOG_STATUS_OK;
}
int main(void) {
TrainlogDatabase *database = NULL;
TrainlogSleepDiaryEntry entry = sample();
@ -69,8 +79,47 @@ int main(void) {
char first_revision[TRAINLOG_SLEEP_REVISION_ID_CAPACITY];
char deleted_revision[TRAINLOG_SLEEP_REVISION_ID_CAPACITY];
size_t count = 0U;
size_t medication_count = 0U;
TrainlogMedication medication = {0};
(void)snprintf(
medication.created_at, sizeof(medication.created_at), "2026-10-24T18:00:00+02:00");
(void)snprintf(
medication.updated_at, sizeof(medication.updated_at), "%s", medication.created_at);
(void)snprintf(medication.name, sizeof(medication.name), "Synthetic medication");
medication.has_default_dose = true;
medication.default_dose_value = 5.0;
(void)snprintf(medication.default_dose_unit, sizeof(medication.default_dose_unit), "mg");
medication.active = true;
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
CHECK(trainlog_medication_create(database, &medication) == TRAINLOG_STATUS_OK);
entry.intake_count = 2U;
(void)trainlog_id_generate(
"mdi", entry.intakes[0].intake_id, sizeof(entry.intakes[0].intake_id));
(void)snprintf(entry.intakes[0].medication_id,
sizeof(entry.intakes[0].medication_id),
"%s",
medication.medication_id);
(void)snprintf(entry.intakes[0].medication_name,
sizeof(entry.intakes[0].medication_name),
"%s",
medication.name);
(void)snprintf(
entry.intakes[0].taken_at, sizeof(entry.intakes[0].taken_at), "2026-10-24T22:30:00+02:00");
(void)snprintf(entry.intakes[0].created_at,
sizeof(entry.intakes[0].created_at),
"%s",
medication.created_at);
entry.intakes[0].has_dose = true;
entry.intakes[0].dose_value = 5.0;
(void)snprintf(entry.intakes[0].dose_unit, sizeof(entry.intakes[0].dose_unit), "mg");
entry.intakes[1] = entry.intakes[0];
(void)trainlog_id_generate(
"mdi", entry.intakes[1].intake_id, sizeof(entry.intakes[1].intake_id));
(void)snprintf(
entry.intakes[1].taken_at, sizeof(entry.intakes[1].taken_at), "2026-10-25T05:15:00+01:00");
entry.intakes[1].dose_value = 10.0;
CHECK(trainlog_sleep_diary_validate(&entry));
CHECK(trainlog_sleep_diary_create(database, &entry) == TRAINLOG_STATUS_OK);
second = sample();
@ -85,7 +134,24 @@ int main(void) {
free(json);
json = NULL;
CHECK(trainlog_sleep_diary_get(database, entry.entry_id, false, &loaded) == TRAINLOG_STATUS_OK);
CHECK(loaded.event_count == 4U && loaded.sleep_quality == TRAINLOG_SLEEP_QUALITY_B);
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);
(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");
CHECK(trainlog_medication_update(database, medication.revision_id, &medication) ==
TRAINLOG_STATUS_OK);
CHECK(strcmp(loaded.intakes[0].medication_name, "Synthetic medication") == 0);
medication.active = false;
(void)snprintf(
medication.updated_at, sizeof(medication.updated_at), "2026-10-26T13:00:00+01:00");
CHECK(trainlog_medication_update(database, medication.revision_id, &medication) ==
TRAINLOG_STATUS_OK);
CHECK(trainlog_medication_list(
database, false, 10U, medication_count_visitor, &medication_count) ==
TRAINLOG_STATUS_OK &&
medication_count == 0U);
(void)snprintf(first_revision, sizeof(first_revision), "%s", loaded.revision_id);
stale = loaded;
loaded.day_form = TRAINLOG_SLEEP_QUALITY_TB;

View file

@ -5,15 +5,19 @@ export type SleepEventType = 'bed_time' | 'final_get_up' | 'night_get_up' | 'sle
'nap' | 'long_awake' | 'half_sleep' | 'daytime_sleepiness'
export interface SleepEvent { event_id: string; type: SleepEventType; start_at: string; end_at: string | null }
export interface MedicationIntake { intake_id: string; medication_id: string; medication_name: string
taken_at: string; dose_value: number | null; dose_unit: string | null; note: string; created_at: string }
export interface SleepMedication { medication_id: string; revision_id: string; name: string
default_dose_value: number | null; default_dose_unit: string | null; form: string; note: string; active: boolean }
export interface SleepEntry {
entry_id: string; night_start_date: string; night_end_date: string
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[]
treatment_and_notes: string; events: SleepEvent[]; intakes: MedicationIntake[]
}
export interface SleepSnapshot {
api_version: 1; entries: SleepEntry[]
summary: { nights: number; long_awake_count: number; nap_count: number; sleepiness_count: number
summary: { nights: number; long_awake_count: number; nap_count: number; sleepiness_count: number; intake_count: number
sleep_duration_seconds: number; long_awake_duration_seconds: number; nap_duration_seconds: number
average_bed_minute: number | null; average_get_up_minute: number | null }
}
@ -34,7 +38,13 @@ function validEntry(value: unknown): value is SleepEntry {
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' &&
eventType(event.type) && typeof event.start_at === 'string' &&
(event.end_at === null || typeof event.end_at === 'string'))
(event.end_at === null || typeof event.end_at === 'string')) && Array.isArray(value.intakes) &&
value.intakes.length <= 32 && value.intakes.every((intake) => object(intake) &&
typeof intake.intake_id === 'string' && typeof intake.medication_id === 'string' &&
typeof intake.medication_name === 'string' && typeof intake.taken_at === 'string' &&
(intake.dose_value === null || typeof intake.dose_value === 'number') &&
(intake.dose_unit === null || typeof intake.dose_unit === 'string') &&
typeof intake.note === 'string' && typeof intake.created_at === 'string')
}
export async function fetchSleepDiary(startDate?: string, endDate?: string,
@ -67,3 +77,27 @@ export const saveSleepEntry = async (entry: SleepEntryInput) => await mutation('
export const deleteSleepEntry = async (entry: SleepEntry) => await mutation('DELETE', {
entry_id: entry.entry_id, expected_revision: entry.revision_id, deleted_at: new Date().toISOString(),
})
export async function fetchSleepMedications(): Promise<SleepMedication[]> {
const response = await fetch('/api/v1/sleep-medications', { headers: { Accept: 'application/json' } })
const value: unknown = await response.json()
if (!response.ok || !object(value) || value.api_version !== 1 || !Array.isArray(value.medications) ||
!value.medications.every((item) => object(item) && typeof item.medication_id === 'string' &&
typeof item.revision_id === 'string' && typeof item.name === 'string' &&
(item.default_dose_value === null || typeof item.default_dose_value === 'number') &&
(item.default_dose_unit === null || typeof item.default_dose_unit === 'string') &&
typeof item.form === 'string' && typeof item.note === 'string' && typeof item.active === 'boolean'))
throw new TypeError('sleep_medications_invalid')
return value.medications as SleepMedication[]
}
export async function saveSleepMedication(input: Omit<SleepMedication, 'revision_id'> & {
expected_revision: string | null; created_at: string; updated_at: string
}): Promise<void> {
const csrf = await mutationCsrfToken()
const response = await fetch('/api/v1/sleep-medications', {
method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json',
'X-Trainlog-CSRF-Token': csrf }, body: JSON.stringify(input),
})
if (!response.ok) throw new Error('sleep_medication_mutation_failed')
}

View file

@ -8,11 +8,13 @@ const entry = (index: number): SleepEntry => ({
updated_at: '2026-09-01T18:00:00+02:00', revision_id: `slr_${index}`, sleep_quality: 'B',
wake_quality: 'Moy', day_form: 'TB', treatment_and_notes: 'Synthetic observation', events: [
{ event_id: `sle_${index}`, type: 'sleep', start_at: '2026-09-01T23:30:00+02:00', end_at: '2026-09-02T06:30:00+02:00' },
],
], intakes: [{ intake_id: `mdi_${index}`, medication_id: `med_${index}`,
medication_name: 'Synthetic medication', taken_at: '2026-09-01T22:30:00+02:00',
dose_value: 5, dose_unit: 'mg', note: '', created_at: '2026-09-01T18:00:00+02:00' }],
})
const snapshot = (count: number): SleepSnapshot => ({ api_version: 1,
entries: Array.from({ length: count }, (_, index) => entry(index)), summary: { nights: count,
long_awake_count: 1, nap_count: 1, sleepiness_count: 1, sleep_duration_seconds: 25_200,
long_awake_count: 1, nap_count: 1, sleepiness_count: 1, intake_count: count, sleep_duration_seconds: 25_200,
long_awake_duration_seconds: 1_800, nap_duration_seconds: 1_200, average_bed_minute: 330,
average_get_up_minute: 750 } })

View file

@ -8,9 +8,9 @@ const labels: Record<SleepEventType, string> = { bed_time: 'v', final_get_up: '^
sleep: 'SLEEP', nap: 'NAP', long_awake: 'AWAKE', half_sleep: 'HALF', daytime_sleepiness: 'S' }
function row(stream: string[], entry: SleepEntry, y: number) {
const timelineX = 112; const width = 530
const timelineX = 112; const width = 500
stream.push(`0.7 G 30 ${y - 40} 782 40 re S\n`)
stream.push(text(33, y - 16, 7, `${entry.night_start_date} > ${entry.night_end_date}`))
stream.push(text(33, y - 16, 5, `${entry.night_start_date} > ${entry.night_end_date}`))
for (let hour = 0; hour <= 24; hour++) { const x = timelineX + hour / 24 * width; stream.push(`0.9 G ${x} ${y - 40} m ${x} ${y} l S\n`) }
entry.events.forEach((event) => {
const x = timelineX + minute(event.start_at, entry.night_start_date) / 1440 * width
@ -21,20 +21,26 @@ function row(stream: string[], entry: SleepEntry, y: number) {
if (eventWidth > 24) stream.push(text(x + 2, y - 23, 5, labels[event.type]))
} else stream.push(text(x, y - 25, 8, labels[event.type]))
})
stream.push(text(648, y - 16, 7, entry.sleep_quality ?? '-')); stream.push(text(680, y - 16, 7, entry.wake_quality ?? '-'))
stream.push(text(712, y - 16, 7, entry.day_form ?? '-')); stream.push(text(744, y - 13, 5, entry.treatment_and_notes.slice(0, 24)))
stream.push(text(744, y - 22, 5, entry.treatment_and_notes.slice(24, 48)))
entry.intakes.forEach((intake) => {
const x = timelineX + minute(intake.taken_at, entry.night_start_date) / 1440 * width
stream.push(text(x, y - 36, 6, 'M'))
})
stream.push(text(620, y - 16, 7, entry.sleep_quality ?? '-')); stream.push(text(658, y - 16, 7, entry.wake_quality ?? '-'))
const medicationText = entry.intakes.map((intake) => `${intake.taken_at.slice(11, 16)} ${intake.medication_name}${intake.dose_value === null ? '' : ` ${intake.dose_value} ${intake.dose_unit}`}`).join('; ')
const notes = [medicationText, entry.treatment_and_notes].filter(Boolean).join(' | ')
stream.push(text(696, y - 16, 7, entry.day_form ?? '-')); stream.push(text(730, y - 13, 4, notes.slice(0, 32)))
stream.push(text(730, y - 22, 4, notes.slice(32, 64)))
}
function page(snapshot: SleepSnapshot, entries: SleepEntry[], index: number, count: number, language: 'fr' | 'en') {
const stream = ['0 G 0 g\n', text(30, 565, 14, language === 'fr' ? 'AGENDA TRAINLOG DE VIGILANCE ET DE SOMMEIL' : 'TRAINLOG SLEEP AND ALERTNESS DIARY'), text(740, 565, 7, `${index + 1}/${count}`), text(30, 548, 7, 'DATE')]
for (let hour = 0; hour <= 24; hour++) stream.push(text(112 + hour / 24 * 530, 548, 5, String((18 + hour) % 24)))
stream.push(text(646, 548, 5, 'SLEEP'), text(678, 548, 5, 'WAKE'), text(710, 548, 5, 'DAY'), text(744, 548, 5, 'NOTES'))
for (let hour = 0; hour <= 24; hour++) stream.push(text(112 + hour / 24 * 500, 548, 4, String((18 + hour) % 24)))
stream.push(text(618, 548, 4, 'SLEEP'), text(656, 548, 4, 'WAKE'), text(694, 548, 4, 'DAY'), text(730, 548, 4, 'TREATMENT / NOTES'))
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}`))
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)}`)))
stream.push(text(30, 18, 6, 'Legend: v bedtime; ^ get-up; S sleepiness; SLEEP sleep; NAP nap; AWAKE long awakening; HALF half-sleep.'))
stream.push(text(30, 18, 6, 'Legend: v bedtime; ^ get-up; S sleepiness; M medication intake; SLEEP sleep; NAP nap; AWAKE long awakening; HALF half-sleep.'))
return stream.join('')
}

View file

@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react'
import { deleteSleepEntry, fetchSleepDiary, saveSleepEntry, type SleepEntry, type SleepEntryInput,
type SleepEvent, type SleepEventType, type SleepQuality } from '../api/sleepDiary'
import { deleteSleepEntry, fetchSleepDiary, fetchSleepMedications, saveSleepEntry, saveSleepMedication,
type MedicationIntake, type SleepEntry, type SleepEntryInput, type SleepEvent, type SleepEventType,
type SleepMedication, type SleepQuality } from '../api/sleepDiary'
import type { AnalysisPeriod } from '../api/analysis'
import { buildSleepDiaryPdf, presentSleepDiaryPdf } from '../report/sleepDiaryPdf'
@ -13,12 +14,16 @@ const copy = {
long_awake: 'Long réveil', half_sleep: 'Demi-sommeil', daytime_sleepiness: 'Somnolence', sleepQuality: 'Qualité du sommeil',
wakeQuality: 'Qualité du réveil', dayForm: 'Forme de la journée', notes: 'Traitement et remarques', add: 'Ajouter',
save: 'Enregistrer', remove: 'Supprimer', observations: 'Observations', summary: 'Synthèse factuelle', empty: 'Aucune nuit enregistrée.',
preview: 'Prévisualiser', export: 'Exporter PDF', qualities: ['Très bon', 'Bon', 'Moyen', 'Mauvais', 'Très mauvais'] },
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'] },
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',
wakeQuality: 'Wake quality', dayForm: 'Day form', notes: 'Treatment and notes', add: 'Add', save: 'Save', remove: 'Delete',
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',
qualities: ['Very good', 'Good', 'Average', 'Bad', 'Very bad'] },
} as const
@ -34,7 +39,7 @@ const blank = (): SleepEntryInput => {
const date = (value: Date) => value.toLocaleDateString('en-CA')
return { entry_id: '', expected_revision: null, night_start_date: date(start), night_end_date: date(end),
created_at: now.toISOString(), updated_at: now.toISOString(), sleep_quality: null, wake_quality: null,
day_form: null, treatment_and_notes: '', events: [] }
day_form: null, treatment_and_notes: '', events: [], intakes: [] }
}
function QualityPicker({ label, value, onChange, language }: { label: string; value: SleepQuality | null;
@ -47,16 +52,38 @@ function QualityPicker({ label, value, onChange, language }: { label: string; va
export function SleepDiaryWorkspace({ period, language }: { period: AnalysisPeriod; language: Language }) {
const t = copy[language]; const [snapshot, setSnapshot] = useState<Awaited<ReturnType<typeof fetchSleepDiary>> | null>(null); const entries = snapshot?.entries ?? []; const [draft, setDraft] = useState<SleepEntryInput>(blank)
const [eventType, setEventType] = useState<SleepEventType>('sleep'); const [pending, setPending] = useState(false); const [error, setError] = useState('')
const [medications, setMedications] = useState<SleepMedication[]>([])
const [medName, setMedName] = useState(''); const [medDose, setMedDose] = useState(''); const [medUnit, setMedUnit] = useState('mg')
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 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 = () => fetchSleepDiary(range.startDate, range.endDate).then(setSnapshot).catch(() => setError('sleep_diary_unavailable'))
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 })) })
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,
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,
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,
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))) }
const addMedication = async () => { if (!medName.trim()) return; const now = new Date().toISOString()
await saveSleepMedication({ medication_id: editingMedication?.medication_id ?? '',
expected_revision: editingMedication?.revision_id ?? null, created_at: now, updated_at: now,
name: medName.trim(), default_dose_value: medDose === '' ? null : Number(medDose),
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) } }
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>
@ -66,7 +93,7 @@ export function SleepDiaryWorkspace({ period, language }: { period: AnalysisPeri
<strong>{t.night} {entry.night_start_date}</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>})}</span>
title={`${t[event.type]} ${event.start_at}${event.end_at ? ` – ${event.end_at}` : ''}`}>{event.type === 'bed_time' ? '↓' : event.type === 'final_get_up' || event.type === 'night_get_up' ? '↑' : event.type === 'daytime_sleepiness' ? 'S' : ''}</i>})}{entry.intakes.map((intake) => <i key={intake.intake_id} className="sleep-event sleep-medication" style={{ left: `${offset(intake.taken_at, entry.night_start_date)}%`, width: '1%' }} title={`${t.medication}: ${intake.medication_name} — ${intake.dose_value === null ? '—' : `${intake.dose_value} ${intake.dose_unit}`} — ${intake.taken_at}`}>M</i>)}</span>
<span>{entry.sleep_quality ?? '—'} / {entry.wake_quality ?? '—'} / {entry.day_form ?? '—'}</span></button>)}</div></div>}
</article>
<article className="analysis-panel analysis-wide"><h2>{t.edit}</h2><div className="sleep-dates"><label>{t.night}<input type="date" value={draft.night_start_date} onChange={(e) => change('night_start_date', e.target.value)} /></label><label>→<input type="date" value={draft.night_end_date} onChange={(e) => change('night_end_date', e.target.value)} /></label></div>
@ -74,9 +101,12 @@ export function SleepDiaryWorkspace({ period, language }: { period: AnalysisPeri
<section><h3>{t.day}</h3><QualityPicker label={t.dayForm} value={draft.day_form} onChange={(value) => change('day_form', value)} language={language}/></section></div>
<div className="sleep-event-add"><label>{t.add}<select value={eventType} onChange={(e) => setEventType(e.target.value as SleepEventType)}>{(['bed_time','sleep','long_awake','night_get_up','final_get_up','half_sleep','nap','daytime_sleepiness'] as const).map((type) => <option key={type} value={type}>{t[type]}</option>)}</select></label><button type="button" onClick={addEvent}>{t.add}</button></div>
<ul className="sleep-event-list">{draft.events.map((event, index) => <li key={`${event.event_id}-${index}`}><strong>{t[event.type]}</strong><label>{t[event.type]}<input type="datetime-local" value={localInput(event.start_at)} onChange={(e) => changeEvent(index, { start_at: absolute(e.target.value) })}/></label>{event.end_at !== null && <label>→<input type="datetime-local" value={localInput(event.end_at)} onChange={(e) => changeEvent(index, { end_at: absolute(e.target.value) })}/></label>}<button type="button" aria-label={`${t.remove} ${t[event.type]}`} onClick={() => change('events', draft.events.filter((_, item) => item !== index))}>×</button></li>)}</ul>
<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>
</article>
<article className="analysis-panel analysis-wide"><h2>{t.medications}</h2><div className="sleep-event-add"><label>{t.medication}<input value={medName} onChange={(e) => setMedName(e.target.value)}/></label><label>{t.usualDose}<input type="number" min="0" step="any" value={medDose} onChange={(e) => setMedDose(e.target.value)}/></label><label>{t.unit}<input value={medUnit} onChange={(e) => setMedUnit(e.target.value)}/></label><button type="button" onClick={() => void addMedication()}>{editingMedication ? t.save : t.addMedication}</button></div><ul className="sleep-event-list">{medications.map((item) => <li key={item.medication_id}><strong>{item.name}</strong><span>{item.default_dose_value === null ? '—' : `${item.default_dose_value} ${item.default_dose_unit}`}</span><button type="button" onClick={() => { setEditingMedication(item); setMedName(item.name); setMedDose(item.default_dose_value?.toString() ?? ''); setMedUnit(item.default_dose_unit ?? 'mg') }}>{t.modify}</button>{item.active && <button type="button" onClick={async () => { const now = new Date().toISOString(); await saveSleepMedication({ ...item, expected_revision: item.revision_id, created_at: now, updated_at: now, active: false }); await reload() }}>{t.deactivate}</button>}</li>)}</ul></article>
<div className="analysis-section-grid"><article className="analysis-panel"><h2>{t.summary}</h2><dl className="analysis-facts"><div><dt>{language === 'fr' ? 'Nuits enregistrées' : 'Recorded nights'}</dt><dd>{entries.length}</dd></div><div><dt>{language === 'fr' ? 'Sommeil déclaré' : 'Declared sleep'}</dt><dd>{Math.round((snapshot?.summary.sleep_duration_seconds ?? 0) / 60)} min</dd></div><div><dt>{language === 'fr' ? 'Longs réveils' : 'Long awakenings'}</dt><dd>{snapshot?.summary.long_awake_count ?? 0}</dd></div><div><dt>{language === 'fr' ? 'Siestes' : 'Naps'}</dt><dd>{snapshot?.summary.nap_count ?? 0}</dd></div></dl></article><article className="analysis-panel"><h2>{t.observations}</h2>{entries.filter((entry) => entry.treatment_and_notes).map((entry) => <p key={entry.entry_id}><strong>{entry.night_start_date}</strong> — {entry.treatment_and_notes}</p>)}</article></div>
</div>
}