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
|
targetSdk = 35
|
||||||
// Bump both for every release installed on the phone — versionName for
|
// Bump both for every release installed on the phone — versionName for
|
||||||
// humans, versionCode so dumpsys/install history can tell builds apart.
|
// humans, versionCode so dumpsys/install history can tell builds apart.
|
||||||
versionCode = 2
|
versionCode = 3
|
||||||
versionName = "0.2.0"
|
versionName = "0.3.0"
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
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 {
|
private companion object {
|
||||||
const val TEST_DB = "migration-test.db"
|
const val TEST_DB = "migration-test.db"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ class SupplyCheckReceiver : BroadcastReceiver() {
|
||||||
for ((_, meds) in byItem) {
|
for ((_, meds) in byItem) {
|
||||||
val item = meds.first().item
|
val item = meds.first().item
|
||||||
var rate = 0.0
|
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)) {
|
if (ScheduleEngine.needsRefill(item.stockUnits, rate, item.lowStockLeadDays)) {
|
||||||
val days = ScheduleEngine.daysOfSupply(item.stockUnits, rate)
|
val days = ScheduleEngine.daysOfSupply(item.stockUnits, rate)
|
||||||
lowStock += "${item.name}: lager for ${no.naiv.meddetsamme.domain.ScheduleText.daysCount(days?.toInt() ?: 0)}"
|
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 daysOfWeekMask: Int,
|
||||||
val intervalDays: Int,
|
val intervalDays: Int,
|
||||||
val anchorEpochDay: Long? = null,
|
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
|
@Serializable
|
||||||
|
|
@ -193,9 +198,15 @@ private fun MedicationDto.toEntity() = Medication(
|
||||||
active = active,
|
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)
|
private fun DoseLog.toDto() = DoseLogDto(id, medId, doseTimeId, scheduledAtMillis, amount, status.name, actionedAtMillis, nagCount)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package no.naiv.meddetsamme.data
|
package no.naiv.meddetsamme.data
|
||||||
|
|
||||||
|
import androidx.room.ColumnInfo
|
||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
import androidx.room.ForeignKey
|
import androidx.room.ForeignKey
|
||||||
import androidx.room.Index
|
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
|
* 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):
|
* Schedule semantics (enforced by the pure ScheduleEngine, not the DB):
|
||||||
* - [intervalDays] > 1 → dose every N days counted from [anchorEpochDay];
|
* - [intervalDays] > 1 → dose every N days counted from [anchorEpochDay];
|
||||||
* [daysOfWeekMask] is ignored.
|
* [daysOfWeekMask] is ignored.
|
||||||
* - otherwise → weekly pattern from [daysOfWeekMask], bit 0 = Sunday … bit 6 =
|
* - otherwise → weekly pattern from [daysOfWeekMask], bit 0 = Sunday … bit 6 =
|
||||||
* Saturday (matches java.time DayOfWeek.SUNDAY rotated; engine owns the mapping).
|
* Saturday. 0x7F = daily.
|
||||||
* 0x7F = daily. Cyclic/taper schedules extend from the same two fields.
|
*
|
||||||
|
* 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(
|
@Entity(
|
||||||
tableName = "dose_time",
|
tableName = "dose_time",
|
||||||
|
|
@ -39,6 +48,16 @@ data class DoseTime(
|
||||||
val daysOfWeekMask: Int = 0x7F,
|
val daysOfWeekMask: Int = 0x7F,
|
||||||
/** 0 or 1 = use the weekly mask; N > 1 = every N days from [anchorEpochDay]. */
|
/** 0 or 1 = use the weekly mask; N > 1 = every N days from [anchorEpochDay]. */
|
||||||
val intervalDays: Int = 0,
|
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,
|
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(
|
@Database(
|
||||||
entities = [InventoryItem::class, Medication::class, DoseTime::class, DoseLog::class],
|
entities = [InventoryItem::class, Medication::class, DoseTime::class, DoseLog::class],
|
||||||
version = 2,
|
version = 3,
|
||||||
exportSchema = true,
|
exportSchema = true,
|
||||||
)
|
)
|
||||||
abstract class MedDatabase : RoomDatabase() {
|
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 =
|
fun get(context: Context): MedDatabase =
|
||||||
instance ?: synchronized(this) {
|
instance ?: synchronized(this) {
|
||||||
instance ?: Room.databaseBuilder(
|
instance ?: Room.databaseBuilder(
|
||||||
context.applicationContext,
|
context.applicationContext,
|
||||||
MedDatabase::class.java,
|
MedDatabase::class.java,
|
||||||
"med-det-samme.db",
|
"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.
|
* - intervalDays > 1 → every N days counted from anchorEpochDay; mask ignored.
|
||||||
* - otherwise → weekly daysOfWeekMask, bit 0 = Sunday … bit 6 = Saturday.
|
* - otherwise → weekly daysOfWeekMask, bit 0 = Sunday … bit 6 = Saturday.
|
||||||
* Mask 0 selects nothing: no occurrences, never an error.
|
* 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 {
|
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). */
|
/** 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
|
fun bitFor(day: DayOfWeek): Int = day.value % 7 // java.time: MON=1..SUN=7
|
||||||
|
|
||||||
/** Does this dose-time occur on [date]? */
|
/** The base weekly/interval rule, before cyclic and window filters. */
|
||||||
fun occursOn(doseTime: DoseTime, date: LocalDate): Boolean {
|
private fun baseOccursOn(doseTime: DoseTime, date: LocalDate): Boolean =
|
||||||
return if (doseTime.intervalDays > 1) {
|
if (doseTime.intervalDays > 1) {
|
||||||
val anchor = doseTime.anchorEpochDay ?: return false
|
val anchor = doseTime.anchorEpochDay ?: return false
|
||||||
val delta = date.toEpochDay() - anchor
|
val delta = date.toEpochDay() - anchor
|
||||||
delta >= 0 && delta % doseTime.intervalDays == 0L
|
delta >= 0 && delta % doseTime.intervalDays == 0L
|
||||||
} else {
|
} else {
|
||||||
doseTime.daysOfWeekMask and (1 shl bitFor(date.dayOfWeek)) != 0
|
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
|
* Earliest occurrence strictly after [after], or null if the schedule never
|
||||||
* fires (empty mask / missing anchor). Strict-after is load-bearing: the
|
* fires within the search horizon (empty mask / missing anchor / window
|
||||||
* alarm receiver passes the occurrence it just fired, and must get the
|
* elapsed). Strict-after is load-bearing: the alarm receiver passes the
|
||||||
* *next* one — never the same minute again.
|
* 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? {
|
fun nextOccurrence(doseTime: DoseTime, after: LocalDateTime): LocalDateTime? {
|
||||||
val time = LocalTime.of(doseTime.minuteOfDay / 60, doseTime.minuteOfDay % 60)
|
val time = LocalTime.of(doseTime.minuteOfDay / 60, doseTime.minuteOfDay % 60)
|
||||||
// First candidate day: today if the time is still ahead of us, else tomorrow.
|
// 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)
|
var date = if (time > after.toLocalTime()) after.toLocalDate() else after.toLocalDate().plusDays(1)
|
||||||
|
repeat(SEARCH_HORIZON_DAYS) {
|
||||||
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) {
|
|
||||||
if (occursOn(doseTime, date)) return LocalDateTime.of(date, time)
|
if (occursOn(doseTime, date)) return LocalDateTime.of(date, time)
|
||||||
date = date.plusDays(1)
|
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). */
|
/** Earliest next occurrence across all of a med's dose-times (what to arm). */
|
||||||
|
|
@ -84,18 +96,28 @@ object ScheduleEngine {
|
||||||
.sortedBy { it.second }
|
.sortedBy { it.second }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scheduled (prospective) consumption in units/day. Weekly: amount × selected
|
* Scheduled (prospective) consumption in units/day on [today]. Weekly:
|
||||||
* days ÷ 7; every-N-days: amount ÷ N. Prospective, not historical, because
|
* amount × selected days ÷ 7; every-N-days: amount ÷ N; cyclic scales by
|
||||||
* days-of-supply is a forward prediction.
|
* 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 =
|
fun dailyConsumption(doseTimes: List<DoseTime>, today: LocalDate): Double {
|
||||||
doseTimes.sumOf { dt ->
|
val day = today.toEpochDay()
|
||||||
if (dt.intervalDays > 1) {
|
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
|
if (dt.anchorEpochDay == null) 0.0 else dt.amount / dt.intervalDays
|
||||||
} else {
|
} else {
|
||||||
dt.amount * Integer.bitCount(dt.daysOfWeekMask and 0x7F) / 7.0
|
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. */
|
/** Days until stock runs out at the scheduled rate; null if nothing is consumed. */
|
||||||
fun daysOfSupply(inventoryUnits: Double, dailyConsumption: Double): Double? =
|
fun daysOfSupply(inventoryUnits: Double, dailyConsumption: Double): Double? =
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
package no.naiv.meddetsamme.domain
|
package no.naiv.meddetsamme.domain
|
||||||
|
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.util.Locale
|
||||||
import no.naiv.meddetsamme.data.DoseTime
|
import no.naiv.meddetsamme.data.DoseTime
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -12,9 +15,30 @@ object ScheduleText {
|
||||||
/** Mask bit order matches ScheduleEngine: bit 0 = Sunday. */
|
/** Mask bit order matches ScheduleEngine: bit 0 = Sunday. */
|
||||||
private val DAY_ABBREV = listOf("søn", "man", "tir", "ons", "tor", "fre", "lør")
|
private val DAY_ABBREV = listOf("søn", "man", "tir", "ons", "tor", "fre", "lør")
|
||||||
|
|
||||||
fun describe(doseTime: DoseTime): String {
|
// Locale-pinned to nb so month names don't come out English on a foreign locale.
|
||||||
val time = "%02d:%02d".format(doseTime.minuteOfDay / 60, doseTime.minuteOfDay % 60)
|
private val dateFmt = DateTimeFormatter.ofPattern("d. MMM", Locale.forLanguageTag("nb"))
|
||||||
return "$time ${daysText(doseTime)}"
|
|
||||||
|
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 {
|
fun daysText(doseTime: DoseTime): String {
|
||||||
|
|
|
||||||
|
|
@ -95,7 +95,7 @@ class DoctorSummaryPdf(private val context: Context) {
|
||||||
for (med in meds) {
|
for (med in meds) {
|
||||||
val item = med.item
|
val item = med.item
|
||||||
val doseTimes = db.doseTimeDao().getForMed(med.med.id)
|
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 supplyDays = ScheduleEngine.daysOfSupply(item.stockUnits, rate)
|
||||||
val logs = db.doseLogDao().getRange(windowStart, System.currentTimeMillis())
|
val logs = db.doseLogDao().getRange(windowStart, System.currentTimeMillis())
|
||||||
.filter { it.medId == med.med.id }
|
.filter { it.medId == med.med.id }
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
||||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
|
@ -294,6 +295,7 @@ private fun ScheduleSection(medId: Long, doseTimes: List<DoseTime>, onChanged: (
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var editing by remember { mutableStateOf<DoseTime?>(null) }
|
var editing by remember { mutableStateOf<DoseTime?>(null) }
|
||||||
var showEditor by remember { mutableStateOf(false) }
|
var showEditor by remember { mutableStateOf(false) }
|
||||||
|
var showTaper by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
Text("Doseringstider", style = MaterialTheme.typography.titleMedium)
|
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()) {
|
OutlinedButton(onClick = { editing = null; showEditor = true }, modifier = Modifier.fillMaxWidth()) {
|
||||||
Text("Legg til doseringstid")
|
Text("Legg til doseringstid")
|
||||||
}
|
}
|
||||||
|
OutlinedButton(onClick = { showTaper = true }, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text("Lag nedtrapping …")
|
||||||
|
}
|
||||||
|
|
||||||
if (showEditor) {
|
if (showEditor) {
|
||||||
DoseTimeEditorDialog(
|
DoseTimeEditorDialog(
|
||||||
|
|
@ -330,6 +335,98 @@ private fun ScheduleSection(medId: Long, doseTimes: List<DoseTime>, onChanged: (
|
||||||
onSaved = { showEditor = false; 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)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||||
|
|
@ -353,12 +450,23 @@ private fun DoseTimeEditorDialog(
|
||||||
var intervalMode by remember { mutableStateOf((existing?.intervalDays ?: 0) > 1) }
|
var intervalMode by remember { mutableStateOf((existing?.intervalDays ?: 0) > 1) }
|
||||||
var mask by remember { mutableStateOf(existing?.daysOfWeekMask ?: 0x7F) }
|
var mask by remember { mutableStateOf(existing?.daysOfWeekMask ?: 0x7F) }
|
||||||
var intervalDays by remember { mutableStateOf((existing?.intervalDays ?: 2).coerceAtLeast(2).toString()) }
|
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(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
title = { Text(if (existing == null) "Ny doseringstid" else "Rediger doseringstid") },
|
title = { Text(if (existing == null) "Ny doseringstid" else "Rediger doseringstid") },
|
||||||
text = {
|
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)
|
TimePicker(state = timeState)
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = amount, onValueChange = { amount = it },
|
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 = {
|
confirmButton = {
|
||||||
TextButton(onClick = {
|
TextButton(onClick = {
|
||||||
val amt = parseAmount(amount) ?: return@TextButton
|
val amt = parseAmount(amount) ?: return@TextButton
|
||||||
val interval = if (intervalMode) (intervalDays.toIntOrNull() ?: 2).coerceIn(2, 365) else 0
|
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(
|
val dt = DoseTime(
|
||||||
id = existing?.id ?: 0,
|
id = existing?.id ?: 0,
|
||||||
medId = medId,
|
medId = medId,
|
||||||
|
|
@ -412,9 +551,13 @@ private fun DoseTimeEditorDialog(
|
||||||
amount = amt,
|
amount = amt,
|
||||||
daysOfWeekMask = if (intervalMode) 0 else mask,
|
daysOfWeekMask = if (intervalMode) 0 else mask,
|
||||||
intervalDays = interval,
|
intervalDays = interval,
|
||||||
anchorEpochDay = if (intervalMode) {
|
anchorEpochDay = if (needsAnchor) {
|
||||||
existing?.anchorEpochDay ?: LocalDate.now().toEpochDay()
|
existing?.anchorEpochDay ?: LocalDate.now().toEpochDay()
|
||||||
} else null,
|
} else null,
|
||||||
|
cycleActiveDays = cycleActive,
|
||||||
|
cycleLengthDays = cycleLength,
|
||||||
|
startEpochDay = existing?.startEpochDay,
|
||||||
|
endEpochDay = existing?.endEpochDay,
|
||||||
)
|
)
|
||||||
scope.launch {
|
scope.launch {
|
||||||
if (existing == null) db.doseTimeDao().insert(dt) else db.doseTimeDao().update(dt)
|
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()
|
val atMillis = at.atZone(zone).toInstant().toEpochMilli()
|
||||||
rows += TodayDose(med, dt, at, logs.find { it.doseTimeId == dt.id && it.scheduledAtMillis == atMillis })
|
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 }) {
|
for (item in meds.map { it.item }.distinctBy { it.id }) {
|
||||||
val rate = ratePerItem[item.id] ?: 0.0
|
val rate = ratePerItem[item.id] ?: 0.0
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,10 @@ class ScheduleEngineTest {
|
||||||
mask: Int = 0x7F,
|
mask: Int = 0x7F,
|
||||||
intervalDays: Int = 0,
|
intervalDays: Int = 0,
|
||||||
anchorEpochDay: Long? = null,
|
anchorEpochDay: Long? = null,
|
||||||
|
cycleActiveDays: Int = 0,
|
||||||
|
cycleLengthDays: Int = 0,
|
||||||
|
startEpochDay: Long? = null,
|
||||||
|
endEpochDay: Long? = null,
|
||||||
id: Long = 1,
|
id: Long = 1,
|
||||||
) = DoseTime(
|
) = DoseTime(
|
||||||
id = id,
|
id = id,
|
||||||
|
|
@ -37,6 +41,10 @@ class ScheduleEngineTest {
|
||||||
daysOfWeekMask = mask,
|
daysOfWeekMask = mask,
|
||||||
intervalDays = intervalDays,
|
intervalDays = intervalDays,
|
||||||
anchorEpochDay = anchorEpochDay,
|
anchorEpochDay = anchorEpochDay,
|
||||||
|
cycleActiveDays = cycleActiveDays,
|
||||||
|
cycleLengthDays = cycleLengthDays,
|
||||||
|
startEpochDay = startEpochDay,
|
||||||
|
endEpochDay = endEpochDay,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun at(date: LocalDate, hour: Int, minute: Int = 0): LocalDateTime =
|
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
|
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 --------------------------------------------
|
// ---- multiple dose-times --------------------------------------------
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -167,13 +247,26 @@ class ScheduleEngineTest {
|
||||||
fun `daily consumption sums weekly rows`() {
|
fun `daily consumption sums weekly rows`() {
|
||||||
// 1.0 every day + 0.5 Mon/Wed/Fri (bits 1,3,5 = 0x2A)
|
// 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))
|
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
|
@Test
|
||||||
fun `daily consumption for every-N-days divides by N`() {
|
fun `daily consumption for every-N-days divides by N`() {
|
||||||
val rows = listOf(doseTime(amount = 1.5, intervalDays = 3, anchorEpochDay = 0))
|
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
|
@Test
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue