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:
Ole-Morten Duesund 2026-06-11 22:09:51 +02:00
commit eeed098b1a
13 changed files with 782 additions and 50 deletions

View file

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

View 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')"
]
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -20,54 +20,66 @@ import no.naiv.meddetsamme.data.DoseTime
* - intervalDays > 1 every N days counted from anchorEpochDay; mask ignored.
* - otherwise weekly daysOfWeekMask, bit 0 = Sunday bit 6 = Saturday.
* Mask 0 selects nothing: no occurrences, never an error.
* - cyclic ([cycleLengthDays] > 0) and validity window ([startEpochDay]/
* [endEpochDay]) further restrict the above see [occursOn].
*/
object ScheduleEngine {
/** How far ahead [nextOccurrence] searches — covers any realistic cycle/window. */
private const val SEARCH_HORIZON_DAYS = 366
/** Bit position for a day-of-week: Sunday=0 … Saturday=6 (brief's convention). */
fun bitFor(day: DayOfWeek): Int = day.value % 7 // java.time: MON=1..SUN=7
/** Does this dose-time occur on [date]? */
fun occursOn(doseTime: DoseTime, date: LocalDate): Boolean {
return if (doseTime.intervalDays > 1) {
/** The base weekly/interval rule, before cyclic and window filters. */
private fun baseOccursOn(doseTime: DoseTime, date: LocalDate): Boolean =
if (doseTime.intervalDays > 1) {
val anchor = doseTime.anchorEpochDay ?: return false
val delta = date.toEpochDay() - anchor
delta >= 0 && delta % doseTime.intervalDays == 0L
} else {
doseTime.daysOfWeekMask and (1 shl bitFor(date.dayOfWeek)) != 0
}
/** Is [date] inside this dose-time's "on" window of its cycle? */
private fun inCycleWindow(doseTime: DoseTime, date: LocalDate): Boolean {
val length = doseTime.cycleLengthDays
val active = doseTime.cycleActiveDays
// Degenerate configs (no anchor, full or empty active span) = no cycle filter.
if (length <= 0 || active !in 1 until length) return true
val anchor = doseTime.anchorEpochDay ?: return true
val day = date.toEpochDay()
if (day < anchor) return false // a cycle can't run before it starts
return Math.floorMod(day - anchor, length.toLong()) < active
}
/** Does this dose-time occur on [date]? Base rule ∧ validity window ∧ cycle. */
fun occursOn(doseTime: DoseTime, date: LocalDate): Boolean {
val day = date.toEpochDay()
doseTime.startEpochDay?.let { if (day < it) return false }
doseTime.endEpochDay?.let { if (day > it) return false }
return baseOccursOn(doseTime, date) && inCycleWindow(doseTime, date)
}
/**
* Earliest occurrence strictly after [after], or null if the schedule never
* fires (empty mask / missing anchor). Strict-after is load-bearing: the
* alarm receiver passes the occurrence it just fired, and must get the
* *next* one never the same minute again.
* fires within the search horizon (empty mask / missing anchor / window
* elapsed). Strict-after is load-bearing: the alarm receiver passes the
* occurrence it just fired, and must get the *next* one never the same
* minute again.
*
* A uniform day-by-day scan over [occursOn] so weekly, interval, cyclic and
* windowed schedules all fall out of one correct path.
*/
fun nextOccurrence(doseTime: DoseTime, after: LocalDateTime): LocalDateTime? {
val time = LocalTime.of(doseTime.minuteOfDay / 60, doseTime.minuteOfDay % 60)
// First candidate day: today if the time is still ahead of us, else tomorrow.
var date = if (time > after.toLocalTime()) after.toLocalDate() else after.toLocalDate().plusDays(1)
if (doseTime.intervalDays > 1) {
val anchor = doseTime.anchorEpochDay ?: return null
val interval = doseTime.intervalDays.toLong()
val delta = date.toEpochDay() - anchor
date = when {
delta <= 0 -> LocalDate.ofEpochDay(anchor) // cycle starts in the future
else -> {
val intoCycle = delta % interval
if (intoCycle == 0L) date else date.plusDays(interval - intoCycle)
}
}
return LocalDateTime.of(date, time)
}
if (doseTime.daysOfWeekMask and 0x7F == 0) return null
repeat(7) {
repeat(SEARCH_HORIZON_DAYS) {
if (occursOn(doseTime, date)) return LocalDateTime.of(date, time)
date = date.plusDays(1)
}
return null // unreachable with a non-empty mask; defensive
return null
}
/** Earliest next occurrence across all of a med's dose-times (what to arm). */
@ -84,18 +96,28 @@ object ScheduleEngine {
.sortedBy { it.second }
/**
* Scheduled (prospective) consumption in units/day. Weekly: amount × selected
* days ÷ 7; every-N-days: amount ÷ N. Prospective, not historical, because
* days-of-supply is a forward prediction.
* Scheduled (prospective) consumption in units/day on [today]. Weekly:
* amount × selected days ÷ 7; every-N-days: amount ÷ N; cyclic scales by
* activeDays ÷ cycleLength. Rows outside their validity window contribute 0
* a finished taper step shouldn't inflate the rate forever. Prospective,
* not historical, because days-of-supply is a forward prediction.
*/
fun dailyConsumption(doseTimes: List<DoseTime>): Double =
doseTimes.sumOf { dt ->
if (dt.intervalDays > 1) {
fun dailyConsumption(doseTimes: List<DoseTime>, today: LocalDate): Double {
val day = today.toEpochDay()
return doseTimes.sumOf { dt ->
if (dt.startEpochDay != null && day < dt.startEpochDay) return@sumOf 0.0
if (dt.endEpochDay != null && day > dt.endEpochDay) return@sumOf 0.0
var rate = if (dt.intervalDays > 1) {
if (dt.anchorEpochDay == null) 0.0 else dt.amount / dt.intervalDays
} else {
dt.amount * Integer.bitCount(dt.daysOfWeekMask and 0x7F) / 7.0
}
if (dt.cycleLengthDays > 0 && dt.cycleActiveDays in 1 until dt.cycleLengthDays) {
rate *= dt.cycleActiveDays.toDouble() / dt.cycleLengthDays
}
rate
}
}
/** Days until stock runs out at the scheduled rate; null if nothing is consumed. */
fun daysOfSupply(inventoryUnits: Double, dailyConsumption: Double): Double? =

View file

@ -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 {

View file

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

View file

@ -21,6 +21,7 @@ import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@ -294,6 +295,7 @@ private fun ScheduleSection(medId: Long, doseTimes: List<DoseTime>, onChanged: (
val scope = rememberCoroutineScope()
var editing by remember { mutableStateOf<DoseTime?>(null) }
var showEditor by remember { mutableStateOf(false) }
var showTaper by remember { mutableStateOf(false) }
Text("Doseringstider", style = MaterialTheme.typography.titleMedium)
@ -321,6 +323,9 @@ private fun ScheduleSection(medId: Long, doseTimes: List<DoseTime>, onChanged: (
OutlinedButton(onClick = { editing = null; showEditor = true }, modifier = Modifier.fillMaxWidth()) {
Text("Legg til doseringstid")
}
OutlinedButton(onClick = { showTaper = true }, modifier = Modifier.fillMaxWidth()) {
Text("Lag nedtrapping …")
}
if (showEditor) {
DoseTimeEditorDialog(
@ -330,6 +335,98 @@ private fun ScheduleSection(medId: Long, doseTimes: List<DoseTime>, onChanged: (
onSaved = { showEditor = false; onChanged() },
)
}
if (showTaper) {
TaperDialog(
medId = medId,
onDismiss = { showTaper = false },
onSaved = { showTaper = false; onChanged() },
)
}
}
/**
* Generates a tapering schedule: one daily dose-time row per step, each with a
* descending amount and an adjacent validity window. Far less tedious than
* hand-entering N windowed rows. Decreasing prednisolone is the canonical case.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TaperDialog(medId: Long, onDismiss: () -> Unit, onSaved: () -> Unit) {
val context = LocalContext.current
val db = remember { MedDatabase.get(context) }
val scope = rememberCoroutineScope()
val timeState = androidx.compose.material3.rememberTimePickerState(initialHour = 8, initialMinute = 0, is24Hour = true)
var startAmount by remember { mutableStateOf("4") }
var stepDown by remember { mutableStateOf("1") }
var daysPerStep by remember { mutableStateOf("3") }
var steps by remember { mutableStateOf("4") }
var error by remember { mutableStateOf<String?>(null) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Lag nedtrapping") },
text = {
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
androidx.compose.material3.TimePicker(state = timeState)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = startAmount, onValueChange = { startAmount = it },
label = { Text("Startdose") }, singleLine = true, modifier = Modifier.weight(1f),
)
OutlinedTextField(
value = stepDown, onValueChange = { stepDown = it },
label = { Text("Ned per trinn") }, singleLine = true, modifier = Modifier.weight(1f),
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = daysPerStep, onValueChange = { daysPerStep = it },
label = { Text("Dager per trinn") }, singleLine = true, modifier = Modifier.weight(1f),
)
OutlinedTextField(
value = steps, onValueChange = { steps = it },
label = { Text("Antall trinn") }, singleLine = true, modifier = Modifier.weight(1f),
)
}
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
}
},
confirmButton = {
TextButton(onClick = {
val start = parseAmount(startAmount)
val step = parseAmount(stepDown)
val perStep = daysPerStep.toIntOrNull()
val n = steps.toIntOrNull()
if (start == null || step == null || perStep == null || n == null || start <= 0 || perStep < 1 || n < 1) {
error = "Fyll inn gyldige tall"
return@TextButton
}
val minute = timeState.hour * 60 + timeState.minute
val today = LocalDate.now().toEpochDay()
val rows = (0 until n).mapNotNull { k ->
val amt = start - k * step
if (amt <= 0) return@mapNotNull null // stop once the dose would hit zero
DoseTime(
medId = medId,
minuteOfDay = minute,
amount = amt,
daysOfWeekMask = 0x7F,
startEpochDay = today + k.toLong() * perStep,
endEpochDay = today + (k + 1).toLong() * perStep - 1,
)
}
scope.launch {
rows.forEach { db.doseTimeDao().insert(it) }
onSaved()
}
}) { Text("Opprett ${steps.toIntOrNull() ?: 0} trinn") }
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Avbryt") } },
)
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@ -353,12 +450,23 @@ private fun DoseTimeEditorDialog(
var intervalMode by remember { mutableStateOf((existing?.intervalDays ?: 0) > 1) }
var mask by remember { mutableStateOf(existing?.daysOfWeekMask ?: 0x7F) }
var intervalDays by remember { mutableStateOf((existing?.intervalDays ?: 2).coerceAtLeast(2).toString()) }
val existingCyclic = (existing?.cycleLengthDays ?: 0) > 0
var cyclic by remember { mutableStateOf(existingCyclic) }
var onDays by remember { mutableStateOf((if (existingCyclic) existing!!.cycleActiveDays else 21).toString()) }
var offDays by remember {
mutableStateOf((if (existingCyclic) existing!!.cycleLengthDays - existing.cycleActiveDays else 7).toString())
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(if (existing == null) "Ny doseringstid" else "Rediger doseringstid") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
// Scrollable: the dialog now has time + amount + mode + cycle and would
// otherwise overflow on small screens.
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
TimePicker(state = timeState)
OutlinedTextField(
value = amount, onValueChange = { amount = it },
@ -399,12 +507,43 @@ private fun DoseTimeEditorDialog(
}
}
}
HorizontalDivider()
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable { cyclic = !cyclic },
) {
Checkbox(checked = cyclic, onCheckedChange = { cyclic = it })
Text("Syklisk (på/av-perioder)")
}
if (cyclic) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = onDays, onValueChange = { onDays = it },
label = { Text("Dager på") }, singleLine = true, modifier = Modifier.weight(1f),
)
OutlinedTextField(
value = offDays, onValueChange = { offDays = it },
label = { Text("Dager av") }, singleLine = true, modifier = Modifier.weight(1f),
)
}
Text(
"F.eks. 21 på / 7 av. Syklusen starter i dag.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
},
confirmButton = {
TextButton(onClick = {
val amt = parseAmount(amount) ?: return@TextButton
val interval = if (intervalMode) (intervalDays.toIntOrNull() ?: 2).coerceIn(2, 365) else 0
val on = onDays.toIntOrNull()?.coerceAtLeast(1) ?: 0
val off = offDays.toIntOrNull()?.coerceAtLeast(0) ?: 0
val cycleActive = if (cyclic && on > 0 && off > 0) on else 0
val cycleLength = if (cycleActive > 0) on + off else 0
val needsAnchor = intervalMode || cycleActive > 0
val dt = DoseTime(
id = existing?.id ?: 0,
medId = medId,
@ -412,9 +551,13 @@ private fun DoseTimeEditorDialog(
amount = amt,
daysOfWeekMask = if (intervalMode) 0 else mask,
intervalDays = interval,
anchorEpochDay = if (intervalMode) {
anchorEpochDay = if (needsAnchor) {
existing?.anchorEpochDay ?: LocalDate.now().toEpochDay()
} else null,
cycleActiveDays = cycleActive,
cycleLengthDays = cycleLength,
startEpochDay = existing?.startEpochDay,
endEpochDay = existing?.endEpochDay,
)
scope.launch {
if (existing == null) db.doseTimeDao().insert(dt) else db.doseTimeDao().update(dt)

View file

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

View file

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