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:
parent
7256c49112
commit
b884885e40
10 changed files with 614 additions and 2 deletions
57
app/src/main/java/no/naiv/meddetsamme/data/DoseLog.kt
Normal file
57
app/src/main/java/no/naiv/meddetsamme/data/DoseLog.kt
Normal 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,
|
||||
)
|
||||
46
app/src/main/java/no/naiv/meddetsamme/data/DoseLogDao.kt
Normal file
46
app/src/main/java/no/naiv/meddetsamme/data/DoseLogDao.kt
Normal 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?)
|
||||
}
|
||||
44
app/src/main/java/no/naiv/meddetsamme/data/DoseTime.kt
Normal file
44
app/src/main/java/no/naiv/meddetsamme/data/DoseTime.kt
Normal 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,
|
||||
)
|
||||
37
app/src/main/java/no/naiv/meddetsamme/data/DoseTimeDao.kt
Normal file
37
app/src/main/java/no/naiv/meddetsamme/data/DoseTimeDao.kt
Normal 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)
|
||||
}
|
||||
38
app/src/main/java/no/naiv/meddetsamme/data/MedDatabase.kt
Normal file
38
app/src/main/java/no/naiv/meddetsamme/data/MedDatabase.kt
Normal 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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
42
app/src/main/java/no/naiv/meddetsamme/data/Medication.kt
Normal file
42
app/src/main/java/no/naiv/meddetsamme/data/Medication.kt
Normal 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,
|
||||
)
|
||||
43
app/src/main/java/no/naiv/meddetsamme/data/MedicationDao.kt
Normal file
43
app/src/main/java/no/naiv/meddetsamme/data/MedicationDao.kt
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue