diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 62fad8c..a1010f4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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" } diff --git a/app/schemas/no.naiv.meddetsamme.data.MedDatabase/3.json b/app/schemas/no.naiv.meddetsamme.data.MedDatabase/3.json new file mode 100644 index 0000000..a2db4ac --- /dev/null +++ b/app/schemas/no.naiv.meddetsamme.data.MedDatabase/3.json @@ -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')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/no/naiv/meddetsamme/data/MigrationTest.kt b/app/src/androidTest/java/no/naiv/meddetsamme/data/MigrationTest.kt index eba6497..e57272e 100644 --- a/app/src/androidTest/java/no/naiv/meddetsamme/data/MigrationTest.kt +++ b/app/src/androidTest/java/no/naiv/meddetsamme/data/MigrationTest.kt @@ -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" } diff --git a/app/src/main/java/no/naiv/meddetsamme/alarm/SupplyCheckReceiver.kt b/app/src/main/java/no/naiv/meddetsamme/alarm/SupplyCheckReceiver.kt index b0ca4a9..27422af 100644 --- a/app/src/main/java/no/naiv/meddetsamme/alarm/SupplyCheckReceiver.kt +++ b/app/src/main/java/no/naiv/meddetsamme/alarm/SupplyCheckReceiver.kt @@ -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)}" diff --git a/app/src/main/java/no/naiv/meddetsamme/backup/BackupSerializer.kt b/app/src/main/java/no/naiv/meddetsamme/backup/BackupSerializer.kt index 5192bd6..7b7f283 100644 --- a/app/src/main/java/no/naiv/meddetsamme/backup/BackupSerializer.kt +++ b/app/src/main/java/no/naiv/meddetsamme/backup/BackupSerializer.kt @@ -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) diff --git a/app/src/main/java/no/naiv/meddetsamme/data/DoseTime.kt b/app/src/main/java/no/naiv/meddetsamme/data/DoseTime.kt index 18168e0..10a9797 100644 --- a/app/src/main/java/no/naiv/meddetsamme/data/DoseTime.kt +++ b/app/src/main/java/no/naiv/meddetsamme/data/DoseTime.kt @@ -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, ) diff --git a/app/src/main/java/no/naiv/meddetsamme/data/MedDatabase.kt b/app/src/main/java/no/naiv/meddetsamme/data/MedDatabase.kt index 78725a6..d893a06 100644 --- a/app/src/main/java/no/naiv/meddetsamme/data/MedDatabase.kt +++ b/app/src/main/java/no/naiv/meddetsamme/data/MedDatabase.kt @@ -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 } } } } diff --git a/app/src/main/java/no/naiv/meddetsamme/domain/ScheduleEngine.kt b/app/src/main/java/no/naiv/meddetsamme/domain/ScheduleEngine.kt index f9bb601..f967cbd 100644 --- a/app/src/main/java/no/naiv/meddetsamme/domain/ScheduleEngine.kt +++ b/app/src/main/java/no/naiv/meddetsamme/domain/ScheduleEngine.kt @@ -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): Double = - doseTimes.sumOf { dt -> - if (dt.intervalDays > 1) { + fun dailyConsumption(doseTimes: List, 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? = diff --git a/app/src/main/java/no/naiv/meddetsamme/domain/ScheduleText.kt b/app/src/main/java/no/naiv/meddetsamme/domain/ScheduleText.kt index fe738d3..029567d 100644 --- a/app/src/main/java/no/naiv/meddetsamme/domain/ScheduleText.kt +++ b/app/src/main/java/no/naiv/meddetsamme/domain/ScheduleText.kt @@ -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 { diff --git a/app/src/main/java/no/naiv/meddetsamme/pdf/DoctorSummaryPdf.kt b/app/src/main/java/no/naiv/meddetsamme/pdf/DoctorSummaryPdf.kt index a1c74b4..ea5031a 100644 --- a/app/src/main/java/no/naiv/meddetsamme/pdf/DoctorSummaryPdf.kt +++ b/app/src/main/java/no/naiv/meddetsamme/pdf/DoctorSummaryPdf.kt @@ -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 } diff --git a/app/src/main/java/no/naiv/meddetsamme/ui/MedEditScreen.kt b/app/src/main/java/no/naiv/meddetsamme/ui/MedEditScreen.kt index d6b4310..faffbf7 100644 --- a/app/src/main/java/no/naiv/meddetsamme/ui/MedEditScreen.kt +++ b/app/src/main/java/no/naiv/meddetsamme/ui/MedEditScreen.kt @@ -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, onChanged: ( val scope = rememberCoroutineScope() var editing by remember { mutableStateOf(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, 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, 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(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) diff --git a/app/src/main/java/no/naiv/meddetsamme/ui/TodayScreen.kt b/app/src/main/java/no/naiv/meddetsamme/ui/TodayScreen.kt index 5870c36..a1265e6 100644 --- a/app/src/main/java/no/naiv/meddetsamme/ui/TodayScreen.kt +++ b/app/src/main/java/no/naiv/meddetsamme/ui/TodayScreen.kt @@ -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 diff --git a/app/src/test/java/no/naiv/meddetsamme/domain/ScheduleEngineTest.kt b/app/src/test/java/no/naiv/meddetsamme/domain/ScheduleEngineTest.kt index 430f498..4988276 100644 --- a/app/src/test/java/no/naiv/meddetsamme/domain/ScheduleEngineTest.kt +++ b/app/src/test/java/no/naiv/meddetsamme/domain/ScheduleEngineTest.kt @@ -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