Data layer: Room entities, DAOs, database with exported schema

Milestone 2. Medication / DoseTime / DoseLog per the brief's model.
DoseLog.doseTimeId is SET_NULL (schedule edits must not erase adherence
history) while medId CASCADEs; DoseLog.amount is snapshotted at logging
time so later dose changes don't rewrite the record. Inventory decrement
is a single SQL UPDATE clamped at 0 to stay race-free under escalating
notifications. Enums stored as TEXT; schema exported to app/schemas as
the migration baseline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-06-10 13:34:41 +02:00
commit b884885e40
10 changed files with 614 additions and 2 deletions

View file

@ -3,6 +3,12 @@ plugins {
// AGP 9 has built-in Kotlin (org.jetbrains.kotlin.android is gone); only the
// compiler sub-plugins are applied separately, pinned to AGP's bundled KGP version.
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.ksp)
}
ksp {
// Commit exported schemas: migration baseline for a DB that is a medical record.
arg("room.schemaLocation", "$projectDir/schemas")
}
android {
@ -48,5 +54,8 @@ dependencies {
implementation(libs.compose.ui.tooling.preview)
debugImplementation(libs.compose.ui.tooling)
implementation(libs.room.runtime)
ksp(libs.room.compiler)
testImplementation(libs.junit)
}

View file

@ -0,0 +1,297 @@
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "d4412a7aa766343df6b08ea6aa76a83a",
"entities": [
{
"tableName": "medication",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `strength` TEXT NOT NULL, `unit` TEXT NOT NULL, `form` TEXT NOT NULL, `withFood` INTEGER NOT NULL, `notes` TEXT NOT NULL, `inventoryUnits` REAL NOT NULL, `packageSize` REAL, `lowStockLeadDays` INTEGER NOT NULL, `rxExpiryEpochDay` INTEGER, `refillsRemaining` INTEGER, `atcCode` TEXT, `active` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "strength",
"columnName": "strength",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "unit",
"columnName": "unit",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "form",
"columnName": "form",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "withFood",
"columnName": "withFood",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notes",
"columnName": "notes",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "inventoryUnits",
"columnName": "inventoryUnits",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "packageSize",
"columnName": "packageSize",
"affinity": "REAL"
},
{
"fieldPath": "lowStockLeadDays",
"columnName": "lowStockLeadDays",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "rxExpiryEpochDay",
"columnName": "rxExpiryEpochDay",
"affinity": "INTEGER"
},
{
"fieldPath": "refillsRemaining",
"columnName": "refillsRemaining",
"affinity": "INTEGER"
},
{
"fieldPath": "atcCode",
"columnName": "atcCode",
"affinity": "TEXT"
},
{
"fieldPath": "active",
"columnName": "active",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
},
{
"tableName": "dose_time",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `medId` INTEGER NOT NULL, `minuteOfDay` INTEGER NOT NULL, `amount` REAL NOT NULL, `daysOfWeekMask` INTEGER NOT NULL, `intervalDays` INTEGER NOT NULL, `anchorEpochDay` INTEGER, FOREIGN KEY(`medId`) REFERENCES `medication`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "medId",
"columnName": "medId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "minuteOfDay",
"columnName": "minuteOfDay",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "amount",
"columnName": "amount",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "daysOfWeekMask",
"columnName": "daysOfWeekMask",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "intervalDays",
"columnName": "intervalDays",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "anchorEpochDay",
"columnName": "anchorEpochDay",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_dose_time_medId",
"unique": false,
"columnNames": [
"medId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_dose_time_medId` ON `${TABLE_NAME}` (`medId`)"
}
],
"foreignKeys": [
{
"table": "medication",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"medId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "dose_log",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `medId` INTEGER NOT NULL, `doseTimeId` INTEGER, `scheduledAtMillis` INTEGER NOT NULL, `amount` REAL NOT NULL, `status` TEXT NOT NULL, `actionedAtMillis` INTEGER, FOREIGN KEY(`medId`) REFERENCES `medication`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`doseTimeId`) REFERENCES `dose_time`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "medId",
"columnName": "medId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "doseTimeId",
"columnName": "doseTimeId",
"affinity": "INTEGER"
},
{
"fieldPath": "scheduledAtMillis",
"columnName": "scheduledAtMillis",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "amount",
"columnName": "amount",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "actionedAtMillis",
"columnName": "actionedAtMillis",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_dose_log_medId",
"unique": false,
"columnNames": [
"medId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_dose_log_medId` ON `${TABLE_NAME}` (`medId`)"
},
{
"name": "index_dose_log_doseTimeId",
"unique": false,
"columnNames": [
"doseTimeId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_dose_log_doseTimeId` ON `${TABLE_NAME}` (`doseTimeId`)"
},
{
"name": "index_dose_log_doseTimeId_scheduledAtMillis",
"unique": false,
"columnNames": [
"doseTimeId",
"scheduledAtMillis"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_dose_log_doseTimeId_scheduledAtMillis` ON `${TABLE_NAME}` (`doseTimeId`, `scheduledAtMillis`)"
},
{
"name": "index_dose_log_status",
"unique": false,
"columnNames": [
"status"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_dose_log_status` ON `${TABLE_NAME}` (`status`)"
}
],
"foreignKeys": [
{
"table": "medication",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"medId"
],
"referencedColumns": [
"id"
]
},
{
"table": "dose_time",
"onDelete": "SET NULL",
"onUpdate": "NO ACTION",
"columns": [
"doseTimeId"
],
"referencedColumns": [
"id"
]
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd4412a7aa766343df6b08ea6aa76a83a')"
]
}
}

View file

@ -0,0 +1,57 @@
package no.naiv.meddetsamme.data
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
enum class DoseStatus { PENDING, TAKEN, SKIPPED, SNOOZED }
/**
* One scheduled occurrence of a dose and what happened to it. Created PENDING
* when the reminder fires (the escalation anchor), resolved by user action.
*
* SNOOZED is a *resting* state between nags the alarm layer treats it like
* PENDING (keeps nagging after the snooze interval) but the UI can distinguish
* "I saw it and deferred" from "never reacted".
*
* [doseTimeId] is nullable and SET_NULL on delete: editing a schedule away must
* not erase adherence history. [medId] CASCADE instead deleting a med (vs.
* deactivating) is an explicit "forget everything" action.
*/
@Entity(
tableName = "dose_log",
foreignKeys = [
ForeignKey(
entity = Medication::class,
parentColumns = ["id"],
childColumns = ["medId"],
onDelete = ForeignKey.CASCADE,
),
ForeignKey(
entity = DoseTime::class,
parentColumns = ["id"],
childColumns = ["doseTimeId"],
onDelete = ForeignKey.SET_NULL,
),
],
indices = [
Index("medId"),
Index("doseTimeId"),
// The reminder layer's hot path: "is there already a log for this occurrence?"
Index(value = ["doseTimeId", "scheduledAtMillis"]),
Index("status"),
],
)
data class DoseLog(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val medId: Long,
val doseTimeId: Long?,
/** The occurrence this log is for (epoch millis of the scheduled local time). */
val scheduledAtMillis: Long,
/** Dose size at the time of logging — schedule edits must not rewrite history. */
val amount: Double,
val status: DoseStatus = DoseStatus.PENDING,
/** When the user last acted on it (Taken/Skipped/Snoozed), null while untouched. */
val actionedAtMillis: Long? = null,
)

View file

@ -0,0 +1,46 @@
package no.naiv.meddetsamme.data
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface DoseLogDao {
@Query("SELECT * FROM dose_log WHERE id = :id")
suspend fun getById(id: Long): DoseLog?
/**
* The escalation anchor: at most one log per (doseTimeId, scheduledAtMillis)
* occurrence the alarm receiver looks it up before creating a duplicate.
*/
@Query("SELECT * FROM dose_log WHERE doseTimeId = :doseTimeId AND scheduledAtMillis = :scheduledAtMillis")
suspend fun getForOccurrence(doseTimeId: Long, scheduledAtMillis: Long): DoseLog?
/** Unresolved doses (PENDING or SNOOZED) — what's still nagging. */
@Query("SELECT * FROM dose_log WHERE status IN ('PENDING', 'SNOOZED') ORDER BY scheduledAtMillis")
suspend fun getUnresolved(): List<DoseLog>
@Query("SELECT * FROM dose_log WHERE scheduledAtMillis >= :fromMillis AND scheduledAtMillis < :toMillis ORDER BY scheduledAtMillis")
fun observeRange(fromMillis: Long, toMillis: Long): Flow<List<DoseLog>>
@Query("SELECT * FROM dose_log WHERE scheduledAtMillis >= :fromMillis AND scheduledAtMillis < :toMillis ORDER BY scheduledAtMillis")
suspend fun getRange(fromMillis: Long, toMillis: Long): List<DoseLog>
/**
* Consumption ground truth for days-of-supply: sum of actually TAKEN amounts
* per med since [fromMillis]. Skipped/snoozed doses consume nothing.
*/
@Query(
"""SELECT COALESCE(SUM(amount), 0) FROM dose_log
WHERE medId = :medId AND status = 'TAKEN' AND scheduledAtMillis >= :fromMillis""",
)
suspend fun takenAmountSince(medId: Long, fromMillis: Long): Double
@Insert
suspend fun insert(log: DoseLog): Long
@Query("UPDATE dose_log SET status = :status, actionedAtMillis = :actionedAtMillis WHERE id = :id")
suspend fun setStatus(id: Long, status: DoseStatus, actionedAtMillis: Long?)
}

View file

@ -0,0 +1,44 @@
package no.naiv.meddetsamme.data
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
/**
* One scheduled time-of-day for a medication. Multiple rows per med give
* different times on different days (e.g. weekend row + weekday row).
*
* Schedule semantics (enforced by the pure ScheduleEngine, not the DB):
* - [intervalDays] > 1 dose every N days counted from [anchorEpochDay];
* [daysOfWeekMask] is ignored.
* - otherwise weekly pattern from [daysOfWeekMask], bit 0 = Sunday bit 6 =
* Saturday (matches java.time DayOfWeek.SUNDAY rotated; engine owns the mapping).
* 0x7F = daily. Cyclic/taper schedules extend from the same two fields.
*/
@Entity(
tableName = "dose_time",
foreignKeys = [
ForeignKey(
entity = Medication::class,
parentColumns = ["id"],
childColumns = ["medId"],
onDelete = ForeignKey.CASCADE,
),
],
indices = [Index("medId")],
)
data class DoseTime(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val medId: Long,
/** Minutes after local midnight, 0..1439. 24h clock everywhere. */
val minuteOfDay: Int,
/** Dose size in the medication's [Medication.unit]s. */
val amount: Double,
/** Bit 0 = Sunday … bit 6 = Saturday. Used when [intervalDays] <= 1. */
val daysOfWeekMask: Int = 0x7F,
/** 0 or 1 = use the weekly mask; N > 1 = every N days from [anchorEpochDay]. */
val intervalDays: Int = 0,
/** Day 0 of an every-N-days cycle (LocalDate.toEpochDay). Required if intervalDays > 1. */
val anchorEpochDay: Long? = null,
)

View file

@ -0,0 +1,37 @@
package no.naiv.meddetsamme.data
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Update
import kotlinx.coroutines.flow.Flow
@Dao
interface DoseTimeDao {
@Query("SELECT * FROM dose_time WHERE medId = :medId ORDER BY minuteOfDay")
suspend fun getForMed(medId: Long): List<DoseTime>
@Query("SELECT * FROM dose_time WHERE medId = :medId ORDER BY minuteOfDay")
fun observeForMed(medId: Long): Flow<List<DoseTime>>
/** Everything the alarm layer needs to (re)arm: all times for active meds. */
@Query(
"""SELECT dose_time.* FROM dose_time
JOIN medication ON medication.id = dose_time.medId
WHERE medication.active = 1""",
)
suspend fun getAllForActiveMeds(): List<DoseTime>
@Query("SELECT * FROM dose_time WHERE id = :id")
suspend fun getById(id: Long): DoseTime?
@Insert
suspend fun insert(doseTime: DoseTime): Long
@Update
suspend fun update(doseTime: DoseTime)
@Query("DELETE FROM dose_time WHERE id = :id")
suspend fun delete(id: Long)
}

View file

@ -0,0 +1,38 @@
package no.naiv.meddetsamme.data
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
/**
* exportSchema=true with schemas/ committed: this DB holds a medical record,
* so every future schema change ships as a tested Migration against the
* exported baseline never destructive fallback.
*
* Enums are stored as TEXT (Room's built-in enum converter); all other columns
* are primitives, so no TypeConverters are needed yet.
*/
@Database(
entities = [Medication::class, DoseTime::class, DoseLog::class],
version = 1,
exportSchema = true,
)
abstract class MedDatabase : RoomDatabase() {
abstract fun medicationDao(): MedicationDao
abstract fun doseTimeDao(): DoseTimeDao
abstract fun doseLogDao(): DoseLogDao
companion object {
@Volatile private var instance: MedDatabase? = null
fun get(context: Context): MedDatabase =
instance ?: synchronized(this) {
instance ?: Room.databaseBuilder(
context.applicationContext,
MedDatabase::class.java,
"med-det-samme.db",
).build().also { instance = it }
}
}
}

View file

@ -0,0 +1,42 @@
package no.naiv.meddetsamme.data
import androidx.room.Entity
import androidx.room.PrimaryKey
enum class MedForm { TABLET, CAPSULE, LIQUID, INJECTION, DROPS, SPRAY, OTHER }
/**
* One medication. Inventory and prescription state live here because both are
* single-valued per med; consumption history lives in [DoseLog].
*
* Deliberate split (brief decision #9): [inventoryUnits] tracks physical stock,
* while [rxExpiryEpochDay]/[refillsRemaining] track the e-resept you can have a
* full box and a dead prescription, or vice versa. Refill warnings are *derived*
* (stock ÷ consumption rate), never a manually scheduled reminder.
*/
@Entity(tableName = "medication")
data class Medication(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val name: String,
/** Per-unit strength as displayed, e.g. "500 mg" or "50 µg/dose". */
val strength: String,
/** What [DoseTime.amount] and [inventoryUnits] count: "tablett", "ml", "doser"… */
val unit: String,
val form: MedForm,
val withFood: Boolean = false,
val notes: String = "",
/** Current physical stock, in [unit]s. Decremented when a dose is TAKEN. */
val inventoryUnits: Double = 0.0,
/** Units per package, for "add one package" restock and the doctor summary. */
val packageSize: Double? = null,
/** Warn when days-of-supply drops below this. */
val lowStockLeadDays: Int = 7,
/** e-resept expiry (LocalDate.toEpochDay), independent of stock. */
val rxExpiryEpochDay: Long? = null,
/** Reiterutleveringer left on the prescription. */
val refillsRemaining: Int? = null,
/** ATC code from FEST autocomplete, for the doctor summary. */
val atcCode: String? = null,
/** Soft delete: history must survive a med being discontinued. */
val active: Boolean = true,
)

View file

@ -0,0 +1,43 @@
package no.naiv.meddetsamme.data
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Update
import kotlinx.coroutines.flow.Flow
@Dao
interface MedicationDao {
@Query("SELECT * FROM medication WHERE active = 1 ORDER BY name")
fun observeActive(): Flow<List<Medication>>
@Query("SELECT * FROM medication WHERE active = 1 ORDER BY name")
suspend fun getActive(): List<Medication>
@Query("SELECT * FROM medication WHERE id = :id")
suspend fun getById(id: Long): Medication?
@Insert
suspend fun insert(medication: Medication): Long
@Update
suspend fun update(medication: Medication)
/**
* Atomic stock decrement on "Taken". Clamped at 0: phantom negative stock
* would poison the derived days-of-supply maths. Done in SQL, not
* read-modify-write, so a nag-notification race can't lose an update.
*/
@Query("UPDATE medication SET inventoryUnits = MAX(0, inventoryUnits - :amount) WHERE id = :id")
suspend fun decrementInventory(id: Long, amount: Double)
@Query("UPDATE medication SET inventoryUnits = inventoryUnits + :amount WHERE id = :id")
suspend fun addInventory(id: Long, amount: Double)
@Query("UPDATE medication SET active = 0 WHERE id = :id")
suspend fun deactivate(id: Long)
@Query("DELETE FROM medication WHERE id = :id")
suspend fun delete(id: Long)
}

View file

@ -26,8 +26,7 @@ compose-material3 = { group = "androidx.compose.material3", name = "material3" }
compose-ui = { group = "androidx.compose.ui", name = "ui" }
compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } # ktx merged into runtime in 2.7+
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
work-runtime = { group = "androidx.work", name = "work-runtime", version.ref = "work" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }