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

@ -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)
}