feat(sleep): add structured medication intakes
This commit is contained in:
parent
53f15e1354
commit
afd1fcc92f
26 changed files with 1405 additions and 42 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)) } }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 } })
|
||||
|
||||
|
|
|
|||
|
|
@ -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('')
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue