Cyclic and tapering dose schedules (schema v3)
Extends DoseTime with two orthogonal filters, as the brief foresaw
('cyclic/taper extend from the same model'):
- Cyclic (cycleActiveDays/cycleLengthDays from anchor): N-on/M-off, e.g.
21/28 contraception. occursOn gains a cycle-window check; nextOccurrence
is now a uniform day-by-day scan over occursOn (replacing the interval
fast-path) so weekly/interval/cyclic/windowed all fall out of one
correct path.
- Validity window (startEpochDay/endEpochDay, inclusive): a taper is
several daily rows with descending amount and adjacent windows.
dailyConsumption now takes : cyclic scales by active/length, and
rows outside their window contribute 0 so a finished taper step stops
inflating days-of-supply.
UI: dose-time dialog gains a Syklisk section (på/av days) and is now
scrollable; a 'Lag nedtrapping' generator creates the windowed rows from
start dose / step-down / days-per-step / step-count. ScheduleText renders
'· syklus 21/28' and '· 11. juni–13. juni'.
Migration 2→3 is additive ADD COLUMNs with defaults matching the entity
(@ColumnInfo defaultValue), proven by an instrumented MigrationTestHelper
test. Backup DTO extended with defaults so v2 files still parse. 9 new
engine unit tests; versionCode 3 / 0.3.0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
dae09ff9fb
commit
eeed098b1a
13 changed files with 782 additions and 50 deletions
|
|
@ -33,8 +33,8 @@ android {
|
|||
targetSdk = 35
|
||||
// Bump both for every release installed on the phone — versionName for
|
||||
// humans, versionCode so dumpsys/install history can tell builds apart.
|
||||
versionCode = 2
|
||||
versionName = "0.2.0"
|
||||
versionCode = 3
|
||||
versionName = "0.3.0"
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
|
|
|
|||
375
app/schemas/no.naiv.meddetsamme.data.MedDatabase/3.json
Normal file
375
app/schemas/no.naiv.meddetsamme.data.MedDatabase/3.json
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 3,
|
||||
"identityHash": "24354334e7b708174aa927342ae33d38",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "inventory_item",
|
||||
"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, `atcCode` TEXT, `packageSize` REAL, `stockUnits` REAL NOT NULL, `lowStockLeadDays` INTEGER NOT NULL, `rxExpiryEpochDay` INTEGER, `refillsRemaining` INTEGER)",
|
||||
"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": "atcCode",
|
||||
"columnName": "atcCode",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "packageSize",
|
||||
"columnName": "packageSize",
|
||||
"affinity": "REAL"
|
||||
},
|
||||
{
|
||||
"fieldPath": "stockUnits",
|
||||
"columnName": "stockUnits",
|
||||
"affinity": "REAL",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lowStockLeadDays",
|
||||
"columnName": "lowStockLeadDays",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "rxExpiryEpochDay",
|
||||
"columnName": "rxExpiryEpochDay",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "refillsRemaining",
|
||||
"columnName": "refillsRemaining",
|
||||
"affinity": "INTEGER"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "medication",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `itemId` INTEGER NOT NULL, `withFood` INTEGER NOT NULL, `notes` TEXT NOT NULL, `active` INTEGER NOT NULL, FOREIGN KEY(`itemId`) REFERENCES `inventory_item`(`id`) ON UPDATE NO ACTION ON DELETE RESTRICT )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "itemId",
|
||||
"columnName": "itemId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "withFood",
|
||||
"columnName": "withFood",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notes",
|
||||
"columnName": "notes",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "active",
|
||||
"columnName": "active",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_medication_itemId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"itemId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_medication_itemId` ON `${TABLE_NAME}` (`itemId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "inventory_item",
|
||||
"onDelete": "RESTRICT",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"itemId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"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, `cycleActiveDays` INTEGER NOT NULL DEFAULT 0, `cycleLengthDays` INTEGER NOT NULL DEFAULT 0, `startEpochDay` INTEGER, `endEpochDay` 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"
|
||||
},
|
||||
{
|
||||
"fieldPath": "cycleActiveDays",
|
||||
"columnName": "cycleActiveDays",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "cycleLengthDays",
|
||||
"columnName": "cycleLengthDays",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "startEpochDay",
|
||||
"columnName": "startEpochDay",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "endEpochDay",
|
||||
"columnName": "endEpochDay",
|
||||
"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, `nagCount` INTEGER NOT NULL, 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"
|
||||
},
|
||||
{
|
||||
"fieldPath": "nagCount",
|
||||
"columnName": "nagCount",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"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, '24354334e7b708174aa927342ae33d38')"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -77,6 +77,37 @@ class MigrationTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun migrate2To3_addsCyclicAndWindowColumnsKeepingRows() {
|
||||
helper.createDatabase(TEST_DB, 2).use { db ->
|
||||
db.execSQL(
|
||||
"INSERT INTO inventory_item (id, name, strength, unit, form, stockUnits, " +
|
||||
"lowStockLeadDays) VALUES (1, 'Levaxin', '50 µg', 'tablett', 'TABLET', 42.0, 7)",
|
||||
)
|
||||
db.execSQL("INSERT INTO medication (id, itemId, withFood, notes, active) VALUES (1, 1, 0, '', 1)")
|
||||
db.execSQL(
|
||||
"INSERT INTO dose_time (id, medId, minuteOfDay, amount, daysOfWeekMask, " +
|
||||
"intervalDays, anchorEpochDay) VALUES (3, 1, 450, 1.5, 127, 0, NULL)",
|
||||
)
|
||||
}
|
||||
|
||||
// Validates the migrated schema against 3.json AND checks the new columns
|
||||
// default to "no cycle / no window" while the existing row survives.
|
||||
helper.runMigrationsAndValidate(TEST_DB, 3, true, MedDatabase.MIGRATION_2_3).use { db ->
|
||||
db.query(
|
||||
"SELECT amount, cycleActiveDays, cycleLengthDays, startEpochDay, endEpochDay " +
|
||||
"FROM dose_time WHERE id = 3",
|
||||
).use { c ->
|
||||
assertTrue(c.moveToFirst())
|
||||
assertEquals(1.5, c.getDouble(0), 0.0)
|
||||
assertEquals(0, c.getInt(1)) // cycleActiveDays
|
||||
assertEquals(0, c.getInt(2)) // cycleLengthDays
|
||||
assertTrue("startEpochDay null", c.isNull(3))
|
||||
assertTrue("endEpochDay null", c.isNull(4))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TEST_DB = "migration-test.db"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ class SupplyCheckReceiver : BroadcastReceiver() {
|
|||
for ((_, meds) in byItem) {
|
||||
val item = meds.first().item
|
||||
var rate = 0.0
|
||||
for (med in meds) rate += ScheduleEngine.dailyConsumption(db.doseTimeDao().getForMed(med.med.id))
|
||||
for (med in meds) rate += ScheduleEngine.dailyConsumption(db.doseTimeDao().getForMed(med.med.id), today)
|
||||
if (ScheduleEngine.needsRefill(item.stockUnits, rate, item.lowStockLeadDays)) {
|
||||
val days = ScheduleEngine.daysOfSupply(item.stockUnits, rate)
|
||||
lowStock += "${item.name}: lager for ${no.naiv.meddetsamme.domain.ScheduleText.daysCount(days?.toInt() ?: 0)}"
|
||||
|
|
|
|||
|
|
@ -157,6 +157,11 @@ data class DoseTimeDto(
|
|||
val daysOfWeekMask: Int,
|
||||
val intervalDays: Int,
|
||||
val anchorEpochDay: Long? = null,
|
||||
// Added in schema v3; defaults keep older (v2) backup files parsing.
|
||||
val cycleActiveDays: Int = 0,
|
||||
val cycleLengthDays: Int = 0,
|
||||
val startEpochDay: Long? = null,
|
||||
val endEpochDay: Long? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
|
@ -193,9 +198,15 @@ private fun MedicationDto.toEntity() = Medication(
|
|||
active = active,
|
||||
)
|
||||
|
||||
private fun DoseTime.toDto() = DoseTimeDto(id, medId, minuteOfDay, amount, daysOfWeekMask, intervalDays, anchorEpochDay)
|
||||
private fun DoseTime.toDto() = DoseTimeDto(
|
||||
id, medId, minuteOfDay, amount, daysOfWeekMask, intervalDays, anchorEpochDay,
|
||||
cycleActiveDays, cycleLengthDays, startEpochDay, endEpochDay,
|
||||
)
|
||||
|
||||
private fun DoseTimeDto.toEntity() = DoseTime(id, medId, minuteOfDay, amount, daysOfWeekMask, intervalDays, anchorEpochDay)
|
||||
private fun DoseTimeDto.toEntity() = DoseTime(
|
||||
id, medId, minuteOfDay, amount, daysOfWeekMask, intervalDays, anchorEpochDay,
|
||||
cycleActiveDays, cycleLengthDays, startEpochDay, endEpochDay,
|
||||
)
|
||||
|
||||
private fun DoseLog.toDto() = DoseLogDto(id, medId, doseTimeId, scheduledAtMillis, amount, status.name, actionedAtMillis, nagCount)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package no.naiv.meddetsamme.data
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
|
|
@ -7,14 +8,22 @@ 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).
|
||||
* different times on different days (e.g. weekend row + weekend 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.
|
||||
* Saturday. 0x7F = daily.
|
||||
*
|
||||
* Two orthogonal filters layer on top (the brief's "cyclic/taper extend from
|
||||
* the same model"):
|
||||
* - **Cyclic** ([cycleLengthDays] > 0): the dose only occurs in the first
|
||||
* [cycleActiveDays] of each [cycleLengthDays]-day cycle counted from
|
||||
* [anchorEpochDay] — e.g. 21-on/7-off contraception is 21/28.
|
||||
* - **Validity window** ([startEpochDay]/[endEpochDay], inclusive): the dose
|
||||
* only occurs within the date range. A taper is several rows with descending
|
||||
* [amount] and adjacent windows.
|
||||
*/
|
||||
@Entity(
|
||||
tableName = "dose_time",
|
||||
|
|
@ -39,6 +48,16 @@ data class DoseTime(
|
|||
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. */
|
||||
/** Day 0 of an every-N-days OR cyclic schedule (LocalDate.toEpochDay). */
|
||||
val anchorEpochDay: Long? = null,
|
||||
// defaultValue "0" makes the entity schema match the v2→v3 ADD COLUMN … DEFAULT 0,
|
||||
// so Room's migration validation passes.
|
||||
/** Days "on" at the start of each cycle. 0 = not cyclic. Needs [anchorEpochDay]. */
|
||||
@ColumnInfo(defaultValue = "0") val cycleActiveDays: Int = 0,
|
||||
/** Total cycle length. 0 = not cyclic. Cyclic requires this > [cycleActiveDays]. */
|
||||
@ColumnInfo(defaultValue = "0") val cycleLengthDays: Int = 0,
|
||||
/** Inclusive validity-window start (LocalDate.toEpochDay); null = no lower bound. */
|
||||
val startEpochDay: Long? = null,
|
||||
/** Inclusive validity-window end; null = no upper bound. Taper rows set this. */
|
||||
val endEpochDay: Long? = null,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
|||
*/
|
||||
@Database(
|
||||
entities = [InventoryItem::class, Medication::class, DoseTime::class, DoseLog::class],
|
||||
version = 2,
|
||||
version = 3,
|
||||
exportSchema = true,
|
||||
)
|
||||
abstract class MedDatabase : RoomDatabase() {
|
||||
|
|
@ -78,13 +78,27 @@ abstract class MedDatabase : RoomDatabase() {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v2 → v3: cyclic + validity-window columns on dose_time. Pure additive
|
||||
* ALTERs with defaults that mean "no cycle, no window" — existing rows
|
||||
* keep behaving exactly as before.
|
||||
*/
|
||||
val MIGRATION_2_3 = object : Migration(2, 3) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE dose_time ADD COLUMN `cycleActiveDays` INTEGER NOT NULL DEFAULT 0")
|
||||
db.execSQL("ALTER TABLE dose_time ADD COLUMN `cycleLengthDays` INTEGER NOT NULL DEFAULT 0")
|
||||
db.execSQL("ALTER TABLE dose_time ADD COLUMN `startEpochDay` INTEGER")
|
||||
db.execSQL("ALTER TABLE dose_time ADD COLUMN `endEpochDay` INTEGER")
|
||||
}
|
||||
}
|
||||
|
||||
fun get(context: Context): MedDatabase =
|
||||
instance ?: synchronized(this) {
|
||||
instance ?: Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
MedDatabase::class.java,
|
||||
"med-det-samme.db",
|
||||
).addMigrations(MIGRATION_1_2).build().also { instance = it }
|
||||
).addMigrations(MIGRATION_1_2, MIGRATION_2_3).build().also { instance = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,54 +20,66 @@ import no.naiv.meddetsamme.data.DoseTime
|
|||
* - intervalDays > 1 → every N days counted from anchorEpochDay; mask ignored.
|
||||
* - otherwise → weekly daysOfWeekMask, bit 0 = Sunday … bit 6 = Saturday.
|
||||
* Mask 0 selects nothing: no occurrences, never an error.
|
||||
* - cyclic ([cycleLengthDays] > 0) and validity window ([startEpochDay]/
|
||||
* [endEpochDay]) further restrict the above — see [occursOn].
|
||||
*/
|
||||
object ScheduleEngine {
|
||||
|
||||
/** How far ahead [nextOccurrence] searches — covers any realistic cycle/window. */
|
||||
private const val SEARCH_HORIZON_DAYS = 366
|
||||
|
||||
/** Bit position for a day-of-week: Sunday=0 … Saturday=6 (brief's convention). */
|
||||
fun bitFor(day: DayOfWeek): Int = day.value % 7 // java.time: MON=1..SUN=7
|
||||
|
||||
/** Does this dose-time occur on [date]? */
|
||||
fun occursOn(doseTime: DoseTime, date: LocalDate): Boolean {
|
||||
return if (doseTime.intervalDays > 1) {
|
||||
/** The base weekly/interval rule, before cyclic and window filters. */
|
||||
private fun baseOccursOn(doseTime: DoseTime, date: LocalDate): Boolean =
|
||||
if (doseTime.intervalDays > 1) {
|
||||
val anchor = doseTime.anchorEpochDay ?: return false
|
||||
val delta = date.toEpochDay() - anchor
|
||||
delta >= 0 && delta % doseTime.intervalDays == 0L
|
||||
} else {
|
||||
doseTime.daysOfWeekMask and (1 shl bitFor(date.dayOfWeek)) != 0
|
||||
}
|
||||
|
||||
/** Is [date] inside this dose-time's "on" window of its cycle? */
|
||||
private fun inCycleWindow(doseTime: DoseTime, date: LocalDate): Boolean {
|
||||
val length = doseTime.cycleLengthDays
|
||||
val active = doseTime.cycleActiveDays
|
||||
// Degenerate configs (no anchor, full or empty active span) = no cycle filter.
|
||||
if (length <= 0 || active !in 1 until length) return true
|
||||
val anchor = doseTime.anchorEpochDay ?: return true
|
||||
val day = date.toEpochDay()
|
||||
if (day < anchor) return false // a cycle can't run before it starts
|
||||
return Math.floorMod(day - anchor, length.toLong()) < active
|
||||
}
|
||||
|
||||
/** Does this dose-time occur on [date]? Base rule ∧ validity window ∧ cycle. */
|
||||
fun occursOn(doseTime: DoseTime, date: LocalDate): Boolean {
|
||||
val day = date.toEpochDay()
|
||||
doseTime.startEpochDay?.let { if (day < it) return false }
|
||||
doseTime.endEpochDay?.let { if (day > it) return false }
|
||||
return baseOccursOn(doseTime, date) && inCycleWindow(doseTime, date)
|
||||
}
|
||||
|
||||
/**
|
||||
* Earliest occurrence strictly after [after], or null if the schedule never
|
||||
* fires (empty mask / missing anchor). Strict-after is load-bearing: the
|
||||
* alarm receiver passes the occurrence it just fired, and must get the
|
||||
* *next* one — never the same minute again.
|
||||
* fires within the search horizon (empty mask / missing anchor / window
|
||||
* elapsed). Strict-after is load-bearing: the alarm receiver passes the
|
||||
* occurrence it just fired, and must get the *next* one — never the same
|
||||
* minute again.
|
||||
*
|
||||
* A uniform day-by-day scan over [occursOn] so weekly, interval, cyclic and
|
||||
* windowed schedules all fall out of one correct path.
|
||||
*/
|
||||
fun nextOccurrence(doseTime: DoseTime, after: LocalDateTime): LocalDateTime? {
|
||||
val time = LocalTime.of(doseTime.minuteOfDay / 60, doseTime.minuteOfDay % 60)
|
||||
// First candidate day: today if the time is still ahead of us, else tomorrow.
|
||||
var date = if (time > after.toLocalTime()) after.toLocalDate() else after.toLocalDate().plusDays(1)
|
||||
|
||||
if (doseTime.intervalDays > 1) {
|
||||
val anchor = doseTime.anchorEpochDay ?: return null
|
||||
val interval = doseTime.intervalDays.toLong()
|
||||
val delta = date.toEpochDay() - anchor
|
||||
date = when {
|
||||
delta <= 0 -> LocalDate.ofEpochDay(anchor) // cycle starts in the future
|
||||
else -> {
|
||||
val intoCycle = delta % interval
|
||||
if (intoCycle == 0L) date else date.plusDays(interval - intoCycle)
|
||||
}
|
||||
}
|
||||
return LocalDateTime.of(date, time)
|
||||
}
|
||||
|
||||
if (doseTime.daysOfWeekMask and 0x7F == 0) return null
|
||||
repeat(7) {
|
||||
repeat(SEARCH_HORIZON_DAYS) {
|
||||
if (occursOn(doseTime, date)) return LocalDateTime.of(date, time)
|
||||
date = date.plusDays(1)
|
||||
}
|
||||
return null // unreachable with a non-empty mask; defensive
|
||||
return null
|
||||
}
|
||||
|
||||
/** Earliest next occurrence across all of a med's dose-times (what to arm). */
|
||||
|
|
@ -84,18 +96,28 @@ object ScheduleEngine {
|
|||
.sortedBy { it.second }
|
||||
|
||||
/**
|
||||
* Scheduled (prospective) consumption in units/day. Weekly: amount × selected
|
||||
* days ÷ 7; every-N-days: amount ÷ N. Prospective, not historical, because
|
||||
* days-of-supply is a forward prediction.
|
||||
* Scheduled (prospective) consumption in units/day on [today]. Weekly:
|
||||
* amount × selected days ÷ 7; every-N-days: amount ÷ N; cyclic scales by
|
||||
* activeDays ÷ cycleLength. Rows outside their validity window contribute 0
|
||||
* — a finished taper step shouldn't inflate the rate forever. Prospective,
|
||||
* not historical, because days-of-supply is a forward prediction.
|
||||
*/
|
||||
fun dailyConsumption(doseTimes: List<DoseTime>): Double =
|
||||
doseTimes.sumOf { dt ->
|
||||
if (dt.intervalDays > 1) {
|
||||
fun dailyConsumption(doseTimes: List<DoseTime>, today: LocalDate): Double {
|
||||
val day = today.toEpochDay()
|
||||
return doseTimes.sumOf { dt ->
|
||||
if (dt.startEpochDay != null && day < dt.startEpochDay) return@sumOf 0.0
|
||||
if (dt.endEpochDay != null && day > dt.endEpochDay) return@sumOf 0.0
|
||||
var rate = if (dt.intervalDays > 1) {
|
||||
if (dt.anchorEpochDay == null) 0.0 else dt.amount / dt.intervalDays
|
||||
} else {
|
||||
dt.amount * Integer.bitCount(dt.daysOfWeekMask and 0x7F) / 7.0
|
||||
}
|
||||
if (dt.cycleLengthDays > 0 && dt.cycleActiveDays in 1 until dt.cycleLengthDays) {
|
||||
rate *= dt.cycleActiveDays.toDouble() / dt.cycleLengthDays
|
||||
}
|
||||
rate
|
||||
}
|
||||
}
|
||||
|
||||
/** Days until stock runs out at the scheduled rate; null if nothing is consumed. */
|
||||
fun daysOfSupply(inventoryUnits: Double, dailyConsumption: Double): Double? =
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
package no.naiv.meddetsamme.domain
|
||||
|
||||
import java.time.LocalDate
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
import no.naiv.meddetsamme.data.DoseTime
|
||||
|
||||
/**
|
||||
|
|
@ -12,9 +15,30 @@ object ScheduleText {
|
|||
/** Mask bit order matches ScheduleEngine: bit 0 = Sunday. */
|
||||
private val DAY_ABBREV = listOf("søn", "man", "tir", "ons", "tor", "fre", "lør")
|
||||
|
||||
fun describe(doseTime: DoseTime): String {
|
||||
val time = "%02d:%02d".format(doseTime.minuteOfDay / 60, doseTime.minuteOfDay % 60)
|
||||
return "$time ${daysText(doseTime)}"
|
||||
// Locale-pinned to nb so month names don't come out English on a foreign locale.
|
||||
private val dateFmt = DateTimeFormatter.ofPattern("d. MMM", Locale.forLanguageTag("nb"))
|
||||
|
||||
fun describe(doseTime: DoseTime): String =
|
||||
"${"%02d:%02d".format(doseTime.minuteOfDay / 60, doseTime.minuteOfDay % 60)} " +
|
||||
"${daysText(doseTime)}${cycleText(doseTime)}${windowText(doseTime)}"
|
||||
|
||||
/** " · syklus 21/28" when cyclic, else "". */
|
||||
fun cycleText(doseTime: DoseTime): String {
|
||||
val length = doseTime.cycleLengthDays
|
||||
val active = doseTime.cycleActiveDays
|
||||
return if (length > 0 && active in 1 until length) " · syklus $active/$length" else ""
|
||||
}
|
||||
|
||||
/** " · 1.–14. jun", " · f.o.m. 1. jun", " · t.o.m. 14. jun" for a validity window. */
|
||||
fun windowText(doseTime: DoseTime): String {
|
||||
val start = doseTime.startEpochDay?.let { LocalDate.ofEpochDay(it) }
|
||||
val end = doseTime.endEpochDay?.let { LocalDate.ofEpochDay(it) }
|
||||
return when {
|
||||
start != null && end != null -> " · ${dateFmt.format(start)}–${dateFmt.format(end)}"
|
||||
start != null -> " · f.o.m. ${dateFmt.format(start)}"
|
||||
end != null -> " · t.o.m. ${dateFmt.format(end)}"
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
|
||||
fun daysText(doseTime: DoseTime): String {
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ class DoctorSummaryPdf(private val context: Context) {
|
|||
for (med in meds) {
|
||||
val item = med.item
|
||||
val doseTimes = db.doseTimeDao().getForMed(med.med.id)
|
||||
val rate = ScheduleEngine.dailyConsumption(doseTimes)
|
||||
val rate = ScheduleEngine.dailyConsumption(doseTimes, today)
|
||||
val supplyDays = ScheduleEngine.daysOfSupply(item.stockUnits, rate)
|
||||
val logs = db.doseLogDao().getRange(windowStart, System.currentTimeMillis())
|
||||
.filter { it.medId == med.med.id }
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
|||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
|
|
@ -294,6 +295,7 @@ private fun ScheduleSection(medId: Long, doseTimes: List<DoseTime>, onChanged: (
|
|||
val scope = rememberCoroutineScope()
|
||||
var editing by remember { mutableStateOf<DoseTime?>(null) }
|
||||
var showEditor by remember { mutableStateOf(false) }
|
||||
var showTaper by remember { mutableStateOf(false) }
|
||||
|
||||
Text("Doseringstider", style = MaterialTheme.typography.titleMedium)
|
||||
|
||||
|
|
@ -321,6 +323,9 @@ private fun ScheduleSection(medId: Long, doseTimes: List<DoseTime>, onChanged: (
|
|||
OutlinedButton(onClick = { editing = null; showEditor = true }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Legg til doseringstid")
|
||||
}
|
||||
OutlinedButton(onClick = { showTaper = true }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Lag nedtrapping …")
|
||||
}
|
||||
|
||||
if (showEditor) {
|
||||
DoseTimeEditorDialog(
|
||||
|
|
@ -330,6 +335,98 @@ private fun ScheduleSection(medId: Long, doseTimes: List<DoseTime>, onChanged: (
|
|||
onSaved = { showEditor = false; onChanged() },
|
||||
)
|
||||
}
|
||||
if (showTaper) {
|
||||
TaperDialog(
|
||||
medId = medId,
|
||||
onDismiss = { showTaper = false },
|
||||
onSaved = { showTaper = false; onChanged() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a tapering schedule: one daily dose-time row per step, each with a
|
||||
* descending amount and an adjacent validity window. Far less tedious than
|
||||
* hand-entering N windowed rows. Decreasing prednisolone is the canonical case.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun TaperDialog(medId: Long, onDismiss: () -> Unit, onSaved: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val db = remember { MedDatabase.get(context) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val timeState = androidx.compose.material3.rememberTimePickerState(initialHour = 8, initialMinute = 0, is24Hour = true)
|
||||
var startAmount by remember { mutableStateOf("4") }
|
||||
var stepDown by remember { mutableStateOf("1") }
|
||||
var daysPerStep by remember { mutableStateOf("3") }
|
||||
var steps by remember { mutableStateOf("4") }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Lag nedtrapping") },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
androidx.compose.material3.TimePicker(state = timeState)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = startAmount, onValueChange = { startAmount = it },
|
||||
label = { Text("Startdose") }, singleLine = true, modifier = Modifier.weight(1f),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = stepDown, onValueChange = { stepDown = it },
|
||||
label = { Text("Ned per trinn") }, singleLine = true, modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = daysPerStep, onValueChange = { daysPerStep = it },
|
||||
label = { Text("Dager per trinn") }, singleLine = true, modifier = Modifier.weight(1f),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = steps, onValueChange = { steps = it },
|
||||
label = { Text("Antall trinn") }, singleLine = true, modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
val start = parseAmount(startAmount)
|
||||
val step = parseAmount(stepDown)
|
||||
val perStep = daysPerStep.toIntOrNull()
|
||||
val n = steps.toIntOrNull()
|
||||
if (start == null || step == null || perStep == null || n == null || start <= 0 || perStep < 1 || n < 1) {
|
||||
error = "Fyll inn gyldige tall"
|
||||
return@TextButton
|
||||
}
|
||||
val minute = timeState.hour * 60 + timeState.minute
|
||||
val today = LocalDate.now().toEpochDay()
|
||||
val rows = (0 until n).mapNotNull { k ->
|
||||
val amt = start - k * step
|
||||
if (amt <= 0) return@mapNotNull null // stop once the dose would hit zero
|
||||
DoseTime(
|
||||
medId = medId,
|
||||
minuteOfDay = minute,
|
||||
amount = amt,
|
||||
daysOfWeekMask = 0x7F,
|
||||
startEpochDay = today + k.toLong() * perStep,
|
||||
endEpochDay = today + (k + 1).toLong() * perStep - 1,
|
||||
)
|
||||
}
|
||||
scope.launch {
|
||||
rows.forEach { db.doseTimeDao().insert(it) }
|
||||
onSaved()
|
||||
}
|
||||
}) { Text("Opprett ${steps.toIntOrNull() ?: 0} trinn") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Avbryt") } },
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||
|
|
@ -353,12 +450,23 @@ private fun DoseTimeEditorDialog(
|
|||
var intervalMode by remember { mutableStateOf((existing?.intervalDays ?: 0) > 1) }
|
||||
var mask by remember { mutableStateOf(existing?.daysOfWeekMask ?: 0x7F) }
|
||||
var intervalDays by remember { mutableStateOf((existing?.intervalDays ?: 2).coerceAtLeast(2).toString()) }
|
||||
val existingCyclic = (existing?.cycleLengthDays ?: 0) > 0
|
||||
var cyclic by remember { mutableStateOf(existingCyclic) }
|
||||
var onDays by remember { mutableStateOf((if (existingCyclic) existing!!.cycleActiveDays else 21).toString()) }
|
||||
var offDays by remember {
|
||||
mutableStateOf((if (existingCyclic) existing!!.cycleLengthDays - existing.cycleActiveDays else 7).toString())
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(if (existing == null) "Ny doseringstid" else "Rediger doseringstid") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
// Scrollable: the dialog now has time + amount + mode + cycle and would
|
||||
// otherwise overflow on small screens.
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
TimePicker(state = timeState)
|
||||
OutlinedTextField(
|
||||
value = amount, onValueChange = { amount = it },
|
||||
|
|
@ -399,12 +507,43 @@ private fun DoseTimeEditorDialog(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.clickable { cyclic = !cyclic },
|
||||
) {
|
||||
Checkbox(checked = cyclic, onCheckedChange = { cyclic = it })
|
||||
Text("Syklisk (på/av-perioder)")
|
||||
}
|
||||
if (cyclic) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = onDays, onValueChange = { onDays = it },
|
||||
label = { Text("Dager på") }, singleLine = true, modifier = Modifier.weight(1f),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = offDays, onValueChange = { offDays = it },
|
||||
label = { Text("Dager av") }, singleLine = true, modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
"F.eks. 21 på / 7 av. Syklusen starter i dag.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
val amt = parseAmount(amount) ?: return@TextButton
|
||||
val interval = if (intervalMode) (intervalDays.toIntOrNull() ?: 2).coerceIn(2, 365) else 0
|
||||
val on = onDays.toIntOrNull()?.coerceAtLeast(1) ?: 0
|
||||
val off = offDays.toIntOrNull()?.coerceAtLeast(0) ?: 0
|
||||
val cycleActive = if (cyclic && on > 0 && off > 0) on else 0
|
||||
val cycleLength = if (cycleActive > 0) on + off else 0
|
||||
val needsAnchor = intervalMode || cycleActive > 0
|
||||
val dt = DoseTime(
|
||||
id = existing?.id ?: 0,
|
||||
medId = medId,
|
||||
|
|
@ -412,9 +551,13 @@ private fun DoseTimeEditorDialog(
|
|||
amount = amt,
|
||||
daysOfWeekMask = if (intervalMode) 0 else mask,
|
||||
intervalDays = interval,
|
||||
anchorEpochDay = if (intervalMode) {
|
||||
anchorEpochDay = if (needsAnchor) {
|
||||
existing?.anchorEpochDay ?: LocalDate.now().toEpochDay()
|
||||
} else null,
|
||||
cycleActiveDays = cycleActive,
|
||||
cycleLengthDays = cycleLength,
|
||||
startEpochDay = existing?.startEpochDay,
|
||||
endEpochDay = existing?.endEpochDay,
|
||||
)
|
||||
scope.launch {
|
||||
if (existing == null) db.doseTimeDao().insert(dt) else db.doseTimeDao().update(dt)
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ fun TodayScreen(nav: Nav) {
|
|||
val atMillis = at.atZone(zone).toInstant().toEpochMilli()
|
||||
rows += TodayDose(med, dt, at, logs.find { it.doseTimeId == dt.id && it.scheduledAtMillis == atMillis })
|
||||
}
|
||||
ratePerItem.merge(med.item.id, ScheduleEngine.dailyConsumption(doseTimes), Double::plus)
|
||||
ratePerItem.merge(med.item.id, ScheduleEngine.dailyConsumption(doseTimes, today), Double::plus)
|
||||
}
|
||||
for (item in meds.map { it.item }.distinctBy { it.id }) {
|
||||
val rate = ratePerItem[item.id] ?: 0.0
|
||||
|
|
|
|||
|
|
@ -28,6 +28,10 @@ class ScheduleEngineTest {
|
|||
mask: Int = 0x7F,
|
||||
intervalDays: Int = 0,
|
||||
anchorEpochDay: Long? = null,
|
||||
cycleActiveDays: Int = 0,
|
||||
cycleLengthDays: Int = 0,
|
||||
startEpochDay: Long? = null,
|
||||
endEpochDay: Long? = null,
|
||||
id: Long = 1,
|
||||
) = DoseTime(
|
||||
id = id,
|
||||
|
|
@ -37,6 +41,10 @@ class ScheduleEngineTest {
|
|||
daysOfWeekMask = mask,
|
||||
intervalDays = intervalDays,
|
||||
anchorEpochDay = anchorEpochDay,
|
||||
cycleActiveDays = cycleActiveDays,
|
||||
cycleLengthDays = cycleLengthDays,
|
||||
startEpochDay = startEpochDay,
|
||||
endEpochDay = endEpochDay,
|
||||
)
|
||||
|
||||
private fun at(date: LocalDate, hour: Int, minute: Int = 0): LocalDateTime =
|
||||
|
|
@ -142,6 +150,78 @@ class ScheduleEngineTest {
|
|||
assertFalse(ScheduleEngine.occursOn(dt, wednesday.minusDays(2))) // before anchor
|
||||
}
|
||||
|
||||
// ---- cyclic (N on / M off) -------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `cyclic 21 of 28 is on during the active window and off after`() {
|
||||
// Daily, anchored on Wednesday, 21 active days of a 28-day cycle.
|
||||
val dt = doseTime(anchorEpochDay = wednesday.toEpochDay(), cycleActiveDays = 21, cycleLengthDays = 28)
|
||||
assertTrue(ScheduleEngine.occursOn(dt, wednesday)) // day 0
|
||||
assertTrue(ScheduleEngine.occursOn(dt, wednesday.plusDays(20))) // last active day
|
||||
assertFalse(ScheduleEngine.occursOn(dt, wednesday.plusDays(21))) // first off day
|
||||
assertFalse(ScheduleEngine.occursOn(dt, wednesday.plusDays(27))) // last off day
|
||||
assertTrue(ScheduleEngine.occursOn(dt, wednesday.plusDays(28))) // next cycle, day 0
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cyclic does not fire before its anchor`() {
|
||||
val dt = doseTime(anchorEpochDay = wednesday.toEpochDay(), cycleActiveDays = 21, cycleLengthDays = 28)
|
||||
assertFalse(ScheduleEngine.occursOn(dt, wednesday.minusDays(1)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nextOccurrence jumps across the cyclic off window`() {
|
||||
// From the last active day, the next dose is at the start of the next cycle.
|
||||
val dt = doseTime(anchorEpochDay = wednesday.toEpochDay(), cycleActiveDays = 21, cycleLengthDays = 28)
|
||||
val next = ScheduleEngine.nextOccurrence(dt, at(wednesday.plusDays(20), 12, 0))
|
||||
assertEquals(at(wednesday.plusDays(28), 8), next) // 7-day gap skipped
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `full active span means no cycle filtering`() {
|
||||
// active >= length is degenerate → behaves as a plain daily dose.
|
||||
val dt = doseTime(anchorEpochDay = wednesday.toEpochDay(), cycleActiveDays = 28, cycleLengthDays = 28)
|
||||
assertTrue(ScheduleEngine.occursOn(dt, wednesday.plusDays(25)))
|
||||
}
|
||||
|
||||
// ---- validity window (taper rows) ------------------------------------
|
||||
|
||||
@Test
|
||||
fun `validity window bounds occurrences inclusively`() {
|
||||
val dt = doseTime(
|
||||
startEpochDay = wednesday.toEpochDay(),
|
||||
endEpochDay = wednesday.plusDays(2).toEpochDay(),
|
||||
)
|
||||
assertFalse(ScheduleEngine.occursOn(dt, wednesday.minusDays(1)))
|
||||
assertTrue(ScheduleEngine.occursOn(dt, wednesday)) // start, inclusive
|
||||
assertTrue(ScheduleEngine.occursOn(dt, wednesday.plusDays(2))) // end, inclusive
|
||||
assertFalse(ScheduleEngine.occursOn(dt, wednesday.plusDays(3)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nextOccurrence is null once the window has elapsed`() {
|
||||
val dt = doseTime(endEpochDay = wednesday.minusDays(1).toEpochDay())
|
||||
assertNull(ScheduleEngine.nextOccurrence(dt, at(wednesday, 6, 0)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nextOccurrence waits for a future window start`() {
|
||||
val dt = doseTime(startEpochDay = saturday.toEpochDay())
|
||||
val next = ScheduleEngine.nextOccurrence(dt, at(wednesday, 6, 0))
|
||||
assertEquals(at(saturday, 8), next)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `taper as adjacent windowed rows yields the right amount each day`() {
|
||||
// 4 mg for 3 days, then 2 mg for 3 days — two rows, descending, adjacent.
|
||||
val step1 = doseTime(amount = 4.0, startEpochDay = wednesday.toEpochDay(), endEpochDay = wednesday.plusDays(2).toEpochDay(), id = 1)
|
||||
val step2 = doseTime(amount = 2.0, startEpochDay = wednesday.plusDays(3).toEpochDay(), endEpochDay = wednesday.plusDays(5).toEpochDay(), id = 2)
|
||||
val rows = listOf(step1, step2)
|
||||
assertEquals(4.0, ScheduleEngine.occurrencesOn(rows, wednesday).single().first.amount, 0.0)
|
||||
assertEquals(2.0, ScheduleEngine.occurrencesOn(rows, wednesday.plusDays(4)).single().first.amount, 0.0)
|
||||
assertTrue(ScheduleEngine.occurrencesOn(rows, wednesday.plusDays(6)).isEmpty())
|
||||
}
|
||||
|
||||
// ---- multiple dose-times --------------------------------------------
|
||||
|
||||
@Test
|
||||
|
|
@ -167,13 +247,26 @@ class ScheduleEngineTest {
|
|||
fun `daily consumption sums weekly rows`() {
|
||||
// 1.0 every day + 0.5 Mon/Wed/Fri (bits 1,3,5 = 0x2A)
|
||||
val rows = listOf(doseTime(amount = 1.0), doseTime(amount = 0.5, mask = 0x2A, id = 2))
|
||||
assertEquals(1.0 + 0.5 * 3 / 7.0, ScheduleEngine.dailyConsumption(rows), 1e-9)
|
||||
assertEquals(1.0 + 0.5 * 3 / 7.0, ScheduleEngine.dailyConsumption(rows, wednesday), 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `daily consumption for every-N-days divides by N`() {
|
||||
val rows = listOf(doseTime(amount = 1.5, intervalDays = 3, anchorEpochDay = 0))
|
||||
assertEquals(0.5, ScheduleEngine.dailyConsumption(rows), 1e-9)
|
||||
assertEquals(0.5, ScheduleEngine.dailyConsumption(rows, wednesday), 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `daily consumption scales by cycle and ignores rows outside window`() {
|
||||
// Daily 1.0 but 21/28 cyclic → 1.0 × 21/28 = 0.75
|
||||
val cyclic = doseTime(amount = 1.0, anchorEpochDay = 0, cycleActiveDays = 21, cycleLengthDays = 28)
|
||||
assertEquals(0.75, ScheduleEngine.dailyConsumption(listOf(cyclic), wednesday), 1e-9)
|
||||
// A taper row whose window has elapsed contributes nothing.
|
||||
val expired = doseTime(amount = 4.0, endEpochDay = wednesday.toEpochDay() - 1)
|
||||
assertEquals(0.0, ScheduleEngine.dailyConsumption(listOf(expired), wednesday), 1e-9)
|
||||
// …and one not yet started likewise.
|
||||
val future = doseTime(amount = 4.0, startEpochDay = wednesday.toEpochDay() + 1)
|
||||
assertEquals(0.0, ScheduleEngine.dailyConsumption(listOf(future), wednesday), 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue