Schedule engine: pure occurrence/supply/renewal math with 22 unit tests

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 <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-06-10 13:36:51 +02:00
commit 63f1ecd9fd
2 changed files with 326 additions and 0 deletions

View file

@ -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<DoseTime>, after: LocalDateTime): Pair<DoseTime, LocalDateTime>? =
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<DoseTime>, date: LocalDate): List<Pair<DoseTime, LocalDateTime>> =
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<DoseTime>): 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
}
}