From afd1fcc92f993d323b0522851f6a1c798feba0ea Mon Sep 17 00:00:00 2001 From: fy59 Date: Mon, 21 Sep 2026 07:01:20 +0200 Subject: [PATCH] feat(sleep): add structured medication intakes --- CHANGELOG.md | 5 +- .../trainlog/data/TrainlogRepository.kt | 217 +++++++++- .../trainlog/model/SleepDiaryModels.kt | 13 +- .../trainlog/ui/SleepDiaryScreen.kt | 89 +++- .../app/src/main/res/values-en/strings.xml | 7 + android/app/src/main/res/values/strings.xml | 7 + .../trainlog/data/SleepDiaryRepositoryTest.kt | 17 +- docs/android.md | 5 + docs/database.md | 9 + docs/sync_exchange.md | 7 + tests/test_sleep_diary_exchange.py | 16 +- tools/sleep_diary_exchange.py | 88 +++- tui/include/trainlog/id.h | 1 + tui/include/trainlog/sleep_diary.h | 54 +++ tui/include/trainlog/web_sleep.h | 9 + tui/src/database.c | 57 +++ tui/src/id.c | 3 +- tui/src/sleep_diary.c | 393 +++++++++++++++++- tui/src/web_server.c | 43 ++ tui/src/web_sleep.c | 199 ++++++++- tui/tests/test_schema_v29_migration.c | 26 ++ tui/tests/test_sleep_diary.c | 68 ++- web/src/api/sleepDiary.ts | 40 +- web/src/report/sleepDiaryPdf.test.ts | 6 +- web/src/report/sleepDiaryPdf.ts | 24 +- web/src/routes/SleepDiaryWorkspace.tsx | 44 +- 26 files changed, 1405 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0326ecb..fed9e8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt index a55ef52..3f2d5c4 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt @@ -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(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 { + 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(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(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(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) { diff --git a/android/app/src/main/java/com/labfytools/trainlog/model/SleepDiaryModels.kt b/android/app/src/main/java/com/labfytools/trainlog/model/SleepDiaryModels.kt index fa56920..410de06 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/model/SleepDiaryModels.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/model/SleepDiaryModels.kt @@ -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, + val treatmentAndNotes: String, val events: List, val intakes: List, ) 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, + val intakes: List = emptyList(), ) diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/SleepDiaryScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/SleepDiaryScreen.kt index d868adf..fb0e233 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/SleepDiaryScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/SleepDiaryScreen.kt @@ -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(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)) } } diff --git a/android/app/src/main/res/values-en/strings.xml b/android/app/src/main/res/values-en/strings.xml index 01b8977..b23e7bf 100644 --- a/android/app/src/main/res/values-en/strings.xml +++ b/android/app/src/main/res/values-en/strings.xml @@ -468,4 +468,11 @@ Complete backup exported. Backup failed; any private staged copy was retained. The backup is invalid or could not be restored. + Medications + Add medication + Add intake + Dose + Unit + Deactivate + Edit diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index d11f463..f4d968a 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -468,4 +468,11 @@ Sauvegarde complète exportée. Échec de la sauvegarde ; toute copie privée créée est conservée. Sauvegarde invalide ou restauration impossible. + Médicaments + Ajouter un médicament + Ajouter une prise + Dosage + Unité + Désactiver + Modifier diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/SleepDiaryRepositoryTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/SleepDiaryRepositoryTest.kt index 47359dd..62babfd 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/SleepDiaryRepositoryTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/SleepDiaryRepositoryTest.kt @@ -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)) diff --git a/docs/android.md b/docs/android.md index 4037a0e..712508b 100644 --- a/docs/android.md +++ b/docs/android.md @@ -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 diff --git a/docs/database.md b/docs/database.md index 1c7be89..04b1357 100644 --- a/docs/database.md +++ b/docs/database.md @@ -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. diff --git a/docs/sync_exchange.md b/docs/sync_exchange.md index 9184939..376773d 100644 --- a/docs/sync_exchange.md +++ b/docs/sync_exchange.md @@ -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 diff --git a/tests/test_sleep_diary_exchange.py b/tests/test_sleep_diary_exchange.py index abdbc9f..d094a1d 100644 --- a/tests/test_sleep_diary_exchange.py +++ b/tests/test_sleep_diary_exchange.py @@ -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" diff --git a/tools/sleep_diary_exchange.py b/tools/sleep_diary_exchange.py index 2c6e745..3283151 100644 --- a/tools/sleep_diary_exchange.py +++ b/tools/sleep_diary_exchange.py @@ -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 diff --git a/tui/include/trainlog/id.h b/tui/include/trainlog/id.h index 8709285..297f235 100644 --- a/tui/include/trainlog/id.h +++ b/tui/include/trainlog/id.h @@ -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. diff --git a/tui/include/trainlog/sleep_diary.h b/tui/include/trainlog/sleep_diary.h index 62ccf70..ee7c3f5 100644 --- a/tui/include/trainlog/sleep_diary.h +++ b/tui/include/trainlog/sleep_diary.h @@ -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 diff --git a/tui/include/trainlog/web_sleep.h b/tui/include/trainlog/web_sleep.h index 0102b05..01a8661 100644 --- a/tui/include/trainlog/web_sleep.h +++ b/tui/include/trainlog/web_sleep.h @@ -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 diff --git a/tui/src/database.c b/tui/src/database.c index 1503804..b528cf0 100644 --- a/tui/src/database.c +++ b/tui/src/database.c @@ -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); } diff --git a/tui/src/id.c b/tui/src/id.c index 3599c6a..e1542c1 100644 --- a/tui/src/id.c +++ b/tui/src/id.c @@ -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) { diff --git a/tui/src/sleep_diary.c b/tui/src/sleep_diary.c index 5e478b4..792669d 100644 --- a/tui/src/sleep_diary.c +++ b/tui/src/sleep_diary.c @@ -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; +} diff --git a/tui/src/web_server.c b/tui/src/web_server.c index 5b37a11..047786e 100644 --- a/tui/src/web_server.c +++ b/tui/src/web_server.c @@ -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; diff --git a/tui/src/web_sleep.c b/tui/src/web_sleep.c index 13dbb48..3048873 100644 --- a/tui/src/web_sleep.c +++ b/tui/src/web_sleep.c @@ -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); +} diff --git a/tui/tests/test_schema_v29_migration.c b/tui/tests/test_schema_v29_migration.c index 750e83c..7fdfdd2 100644 --- a/tui/tests/test_schema_v29_migration.c +++ b/tui/tests/test_schema_v29_migration.c @@ -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) { diff --git a/tui/tests/test_sleep_diary.c b/tui/tests/test_sleep_diary.c index 3310e22..0274d8f 100644 --- a/tui/tests/test_sleep_diary.c +++ b/tui/tests/test_sleep_diary.c @@ -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; diff --git a/web/src/api/sleepDiary.ts b/web/src/api/sleepDiary.ts index af7df1d..bfd1509 100644 --- a/web/src/api/sleepDiary.ts +++ b/web/src/api/sleepDiary.ts @@ -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 { + 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 & { + expected_revision: string | null; created_at: string; updated_at: string +}): Promise { + 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') +} diff --git a/web/src/report/sleepDiaryPdf.test.ts b/web/src/report/sleepDiaryPdf.test.ts index 1233444..0ff8b93 100644 --- a/web/src/report/sleepDiaryPdf.test.ts +++ b/web/src/report/sleepDiaryPdf.test.ts @@ -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 } }) diff --git a/web/src/report/sleepDiaryPdf.ts b/web/src/report/sleepDiaryPdf.ts index f246c9f..2bd0130 100644 --- a/web/src/report/sleepDiaryPdf.ts +++ b/web/src/report/sleepDiaryPdf.ts @@ -8,9 +8,9 @@ const labels: Record = { 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('') } diff --git a/web/src/routes/SleepDiaryWorkspace.tsx b/web/src/routes/SleepDiaryWorkspace.tsx index c64e20d..3e773be 100644 --- a/web/src/routes/SleepDiaryWorkspace.tsx +++ b/web/src/routes/SleepDiaryWorkspace.tsx @@ -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> | null>(null); const entries = snapshot?.entries ?? []; const [draft, setDraft] = useState(blank) const [eventType, setEventType] = useState('sleep'); const [pending, setPending] = useState(false); const [error, setError] = useState('') + const [medications, setMedications] = useState([]) + const [medName, setMedName] = useState(''); const [medDose, setMedDose] = useState(''); const [medUnit, setMedUnit] = useState('mg') + const [editingMedication, setEditingMedication] = useState(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 = (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) => 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

{t.agenda}

@@ -66,7 +93,7 @@ export function SleepDiaryWorkspace({ period, language }: { period: AnalysisPeri {t.night} {entry.night_start_date}{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 {event.type === 'bed_time' ? '↓' : event.type === 'final_get_up' || event.type === 'night_get_up' ? '↑' : event.type === 'daytime_sleepiness' ? 'S' : ''}})} + 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' : ''}})}{entry.intakes.map((intake) => M)} {entry.sleep_quality ?? '—'} / {entry.wake_quality ?? '—'} / {entry.day_form ?? '—'})}
}

{t.edit}

@@ -74,9 +101,12 @@ export function SleepDiaryWorkspace({ period, language }: { period: AnalysisPeri

{t.day}

change('day_form', value)} language={language}/>
    {draft.events.map((event, index) =>
  • {t[event.type]}{event.end_at !== null && }
  • )}
+

{t.medications}

+
    {draft.intakes.map((intake, index) =>
  • {intake.medication_name}
  • )}