From 63f1ecd9fdf47dea329bcb586d0466b2a7c201a3 Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Wed, 10 Jun 2026 13:36:51 +0200 Subject: [PATCH] Schedule engine: pure occurrence/supply/renewal math with 22 unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Milestone 3. Single authority for DoseTime semantics: weekly mask (bit 0 = Sunday) vs every-N-days from anchor (mask ignored when intervalDays > 1). nextOccurrence is strictly-after by contract so the alarm layer can pass the occurrence it just fired and never re-arm the same minute. Wall-clock LocalDateTime throughout — instant conversion happens at the alarm edge, so an 08:00 dose stays 08:00 across DST. Consumption rate is prospective (from schedule) because days-of-supply predicts forward; historical consumption stays in the DAO. Co-Authored-By: Claude Fable 5 --- .../naiv/meddetsamme/domain/ScheduleEngine.kt | 118 ++++++++++ .../meddetsamme/domain/ScheduleEngineTest.kt | 208 ++++++++++++++++++ 2 files changed, 326 insertions(+) create mode 100644 app/src/main/java/no/naiv/meddetsamme/domain/ScheduleEngine.kt create mode 100644 app/src/test/java/no/naiv/meddetsamme/domain/ScheduleEngineTest.kt diff --git a/app/src/main/java/no/naiv/meddetsamme/domain/ScheduleEngine.kt b/app/src/main/java/no/naiv/meddetsamme/domain/ScheduleEngine.kt new file mode 100644 index 0000000..a07ca6c --- /dev/null +++ b/app/src/main/java/no/naiv/meddetsamme/domain/ScheduleEngine.kt @@ -0,0 +1,118 @@ +package no.naiv.meddetsamme.domain + +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime +import no.naiv.meddetsamme.data.DoseTime + +/** + * All schedule and supply math, pure and side-effect free. The alarm layer, + * UI, and supply checks all *re-ask* this engine instead of storing derived + * state — reboot, app update, and "Taken" therefore can't drift. + * + * Everything works in local wall-clock time (LocalDate/LocalDateTime); the + * caller converts to an instant at the edge. A dose "at 08:00" means 08:00 + * on the wall clock regardless of DST shifts — which is what a human taking + * medication expects. + * + * Schedule semantics (the single authority for [DoseTime]'s fields): + * - 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. + */ +object ScheduleEngine { + + /** 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) { + 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 + } + } + + /** + * 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. + */ + 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) { + if (occursOn(doseTime, date)) return LocalDateTime.of(date, time) + date = date.plusDays(1) + } + return null // unreachable with a non-empty mask; defensive + } + + /** Earliest next occurrence across all of a med's dose-times (what to arm). */ + fun nextOccurrence(doseTimes: List, after: LocalDateTime): Pair? = + doseTimes + .mapNotNull { dt -> nextOccurrence(dt, after)?.let { dt to it } } + .minByOrNull { it.second } + + /** All occurrences on [date], in time order — the "today" screen's source. */ + fun occurrencesOn(doseTimes: List, date: LocalDate): List> = + doseTimes + .filter { occursOn(it, date) } + .map { it to LocalDateTime.of(date, LocalTime.of(it.minuteOfDay / 60, it.minuteOfDay % 60)) } + .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. + */ + fun dailyConsumption(doseTimes: List): Double = + doseTimes.sumOf { dt -> + 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 + } + } + + /** Days until stock runs out at the scheduled rate; null if nothing is consumed. */ + fun daysOfSupply(inventoryUnits: Double, dailyConsumption: Double): Double? = + if (dailyConsumption <= 0.0) null else inventoryUnits / dailyConsumption + + /** Refill warning: supply (at scheduled rate) won't outlast the lead time. */ + fun needsRefill(inventoryUnits: Double, dailyConsumption: Double, lowStockLeadDays: Int): Boolean { + val supply = daysOfSupply(inventoryUnits, dailyConsumption) ?: return false + return supply <= lowStockLeadDays + } + + /** + * Renewal warning: e-resept expires within [leadDays] (or already has). + * Independent of stock by design — a full box doesn't renew a prescription. + */ + fun needsRenewal(rxExpiryEpochDay: Long?, today: LocalDate, leadDays: Int = 14): Boolean { + if (rxExpiryEpochDay == null) return false + return rxExpiryEpochDay - today.toEpochDay() <= leadDays + } +} diff --git a/app/src/test/java/no/naiv/meddetsamme/domain/ScheduleEngineTest.kt b/app/src/test/java/no/naiv/meddetsamme/domain/ScheduleEngineTest.kt new file mode 100644 index 0000000..430f498 --- /dev/null +++ b/app/src/test/java/no/naiv/meddetsamme/domain/ScheduleEngineTest.kt @@ -0,0 +1,208 @@ +package no.naiv.meddetsamme.domain + +import java.time.LocalDate +import java.time.LocalDateTime +import no.naiv.meddetsamme.data.DoseTime +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The schedule engine is the safety core of a medication app — these tests are + * the contract. Fixed reference dates (no clock access) keep every case + * deterministic. + * + * Calendar anchor used throughout: 2026-06-10 is a WEDNESDAY. + */ +class ScheduleEngineTest { + + private val wednesday: LocalDate = LocalDate.of(2026, 6, 10) + private val saturday: LocalDate = LocalDate.of(2026, 6, 13) + private val sunday: LocalDate = LocalDate.of(2026, 6, 14) + + private fun doseTime( + minuteOfDay: Int = 8 * 60, // 08:00 + amount: Double = 1.0, + mask: Int = 0x7F, + intervalDays: Int = 0, + anchorEpochDay: Long? = null, + id: Long = 1, + ) = DoseTime( + id = id, + medId = 1, + minuteOfDay = minuteOfDay, + amount = amount, + daysOfWeekMask = mask, + intervalDays = intervalDays, + anchorEpochDay = anchorEpochDay, + ) + + private fun at(date: LocalDate, hour: Int, minute: Int = 0): LocalDateTime = + LocalDateTime.of(date, java.time.LocalTime.of(hour, minute)) + + // ---- weekly mask ----------------------------------------------------- + + @Test + fun `daily dose later today fires today`() { + val next = ScheduleEngine.nextOccurrence(doseTime(), at(wednesday, 6, 0)) + assertEquals(at(wednesday, 8), next) + } + + @Test + fun `daily dose already past fires tomorrow`() { + val next = ScheduleEngine.nextOccurrence(doseTime(), at(wednesday, 9, 0)) + assertEquals(at(wednesday.plusDays(1), 8), next) + } + + @Test + fun `occurrence is strictly after - same minute rolls to next day`() { + // The alarm just fired at 08:00; asking from 08:00 must give tomorrow. + val next = ScheduleEngine.nextOccurrence(doseTime(), at(wednesday, 8, 0)) + assertEquals(at(wednesday.plusDays(1), 8), next) + } + + @Test + fun `weekend-only mask from wednesday fires saturday`() { + // bit 0 = Sunday, bit 6 = Saturday → weekend = 0b1000001 = 0x41 + val next = ScheduleEngine.nextOccurrence(doseTime(mask = 0x41), at(wednesday, 12, 0)) + assertEquals(at(saturday, 8), next) + } + + @Test + fun `sunday-only mask wraps the week correctly`() { + val next = ScheduleEngine.nextOccurrence(doseTime(mask = 0x01), at(sunday, 9, 0)) + assertEquals(at(sunday.plusDays(7), 8), next) // past Sunday's time → next Sunday + } + + @Test + fun `empty mask never fires`() { + assertNull(ScheduleEngine.nextOccurrence(doseTime(mask = 0), at(wednesday, 6, 0))) + } + + @Test + fun `occursOn respects mask`() { + val weekendDose = doseTime(mask = 0x41) + assertTrue(ScheduleEngine.occursOn(weekendDose, saturday)) + assertTrue(ScheduleEngine.occursOn(weekendDose, sunday)) + assertFalse(ScheduleEngine.occursOn(weekendDose, wednesday)) + } + + // ---- every-N-days ---------------------------------------------------- + + @Test + fun `every 2 days on a cycle day before dose time fires same day`() { + val dt = doseTime(intervalDays = 2, anchorEpochDay = wednesday.toEpochDay()) + val next = ScheduleEngine.nextOccurrence(dt, at(wednesday, 6, 0)) + assertEquals(at(wednesday, 8), next) + } + + @Test + fun `every 2 days past dose time skips to next cycle day`() { + val dt = doseTime(intervalDays = 2, anchorEpochDay = wednesday.toEpochDay()) + val next = ScheduleEngine.nextOccurrence(dt, at(wednesday, 9, 0)) + assertEquals(at(wednesday.plusDays(2), 8), next) + } + + @Test + fun `every 3 days from mid-cycle lands on cycle day not just any day`() { + val dt = doseTime(intervalDays = 3, anchorEpochDay = wednesday.toEpochDay()) + // Thursday is 1 day into a 3-day cycle → next fire is Saturday (+3 from anchor). + val next = ScheduleEngine.nextOccurrence(dt, at(wednesday.plusDays(1), 12, 0)) + assertEquals(at(saturday, 8), next) + } + + @Test + fun `anchor in the future means first occurrence is the anchor day`() { + val dt = doseTime(intervalDays = 2, anchorEpochDay = saturday.toEpochDay()) + val next = ScheduleEngine.nextOccurrence(dt, at(wednesday, 12, 0)) + assertEquals(at(saturday, 8), next) + } + + @Test + fun `interval schedule ignores the weekly mask`() { + // Mask says "never", interval says every day — interval wins when > 1. + val dt = doseTime(mask = 0, intervalDays = 2, anchorEpochDay = wednesday.toEpochDay()) + val next = ScheduleEngine.nextOccurrence(dt, at(wednesday, 6, 0)) + assertEquals(at(wednesday, 8), next) + } + + @Test + fun `interval without anchor never fires`() { + assertNull(ScheduleEngine.nextOccurrence(doseTime(intervalDays = 2), at(wednesday, 6, 0))) + } + + @Test + fun `occursOn for interval requires day on cycle and not before anchor`() { + val dt = doseTime(intervalDays = 2, anchorEpochDay = wednesday.toEpochDay()) + assertTrue(ScheduleEngine.occursOn(dt, wednesday)) + assertFalse(ScheduleEngine.occursOn(dt, wednesday.plusDays(1))) + assertTrue(ScheduleEngine.occursOn(dt, wednesday.plusDays(2))) + assertFalse(ScheduleEngine.occursOn(dt, wednesday.minusDays(2))) // before anchor + } + + // ---- multiple dose-times -------------------------------------------- + + @Test + fun `nextOccurrence over a list picks the earliest across rows`() { + val morning = doseTime(minuteOfDay = 8 * 60, id = 1) + val evening = doseTime(minuteOfDay = 20 * 60, id = 2) + val next = ScheduleEngine.nextOccurrence(listOf(evening, morning), at(wednesday, 9, 0)) + assertEquals(evening to at(wednesday, 20), next) // morning already past + } + + @Test + fun `occurrencesOn lists a day's doses in time order`() { + val morning = doseTime(minuteOfDay = 8 * 60, id = 1) + val evening = doseTime(minuteOfDay = 20 * 60, id = 2) + val weekendOnly = doseTime(minuteOfDay = 12 * 60, mask = 0x41, id = 3) + val today = ScheduleEngine.occurrencesOn(listOf(evening, weekendOnly, morning), wednesday) + assertEquals(listOf(1L, 2L), today.map { it.first.id }) // weekend row absent, sorted + } + + // ---- supply / refill / renewal --------------------------------------- + + @Test + 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) + } + + @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) + } + + @Test + fun `days of supply divides stock by rate`() { + assertEquals(7.0, ScheduleEngine.daysOfSupply(14.0, 2.0)!!, 1e-9) + assertNull(ScheduleEngine.daysOfSupply(14.0, 0.0)) // nothing scheduled → no horizon + } + + @Test + fun `needsRefill at exactly the lead boundary warns`() { + assertTrue(ScheduleEngine.needsRefill(14.0, 2.0, lowStockLeadDays = 7)) + assertFalse(ScheduleEngine.needsRefill(14.1, 2.0, lowStockLeadDays = 7)) + assertFalse(ScheduleEngine.needsRefill(14.0, 0.0, lowStockLeadDays = 7)) + } + + @Test + fun `needsRenewal is independent of stock and handles null and expired`() { + assertFalse(ScheduleEngine.needsRenewal(null, wednesday)) + assertTrue(ScheduleEngine.needsRenewal(wednesday.toEpochDay() + 14, wednesday)) // boundary + assertFalse(ScheduleEngine.needsRenewal(wednesday.toEpochDay() + 15, wednesday)) + assertTrue(ScheduleEngine.needsRenewal(wednesday.toEpochDay() - 1, wednesday)) // already dead + } + + // ---- mask bit convention -------------------------------------------- + + @Test + fun `bit convention is Sunday=0 through Saturday=6`() { + assertEquals(0, ScheduleEngine.bitFor(java.time.DayOfWeek.SUNDAY)) + assertEquals(1, ScheduleEngine.bitFor(java.time.DayOfWeek.MONDAY)) + assertEquals(6, ScheduleEngine.bitFor(java.time.DayOfWeek.SATURDAY)) + } +}