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

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