Seasonal (yearly) dosing for allergy/pollen meds

Add a recurring month-day season window to DoseTime (seasonStartMmdd/
seasonEndMmdd): a med rests outside its window and re-arms automatically
every year. The pure ScheduleEngine gains a season filter plus one-year
overrides -- startEpochDay/endEpochDay act as early-start/extend brackets
for seasonal rows while staying hard bounds for tapers.

- Daily SupplyCheck nudges before a season opens (Start naa) and before it
  closes (Forleng), handled by the new SeasonActionReceiver.
- Med list lists resting seasonal meds in a "Sesong (hviler)" section.
- Dose-time editor gains a season block and a "scroll for more" hint so
  cyclic/season below the time picker are discoverable.
- Room migration v3->v4 (additive), backup DTO extended, engine/text tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-06-24 11:09:46 +02:00
commit d81a4fd926
15 changed files with 1098 additions and 103 deletions

View file

@ -4,11 +4,13 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import java.time.LocalDate
import java.time.temporal.ChronoUnit
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import no.naiv.meddetsamme.data.MedDatabase
import no.naiv.meddetsamme.domain.ScheduleEngine
import no.naiv.meddetsamme.domain.ScheduleText
import no.naiv.meddetsamme.notify.Notifications
/**
@ -33,6 +35,12 @@ class SupplyCheckReceiver : BroadcastReceiver() {
private companion object {
/** "About a week in advance" for prescription renewal. */
const val RX_LEAD_DAYS = 7
/** How early to nudge before a resting seasonal med's window opens. */
const val SEASON_START_LEAD_DAYS = 7L
/** How early to offer "Forleng" before the current season closes. */
const val SEASON_END_LEAD_DAYS = 5L
}
private suspend fun check(context: Context) {
@ -41,16 +49,19 @@ class SupplyCheckReceiver : BroadcastReceiver() {
val lowStock = mutableListOf<String>()
val rxRenewal = mutableListOf<String>()
val meds = db.medicationDao().getActive()
// Dose-times feed both the supply rate and the season check; fetch once.
val dtsByMed = meds.associate { it.med.id to db.doseTimeDao().getForMed(it.med.id) }
// Per inventory item: consumption is the SUM across all active regimens
// drawing from it — one shared box drains faster than either alone.
val byItem = db.medicationDao().getActive().groupBy { it.item.id }
for ((_, meds) in byItem) {
val item = meds.first().item
for ((_, group) in meds.groupBy { it.item.id }) {
val item = group.first().item
var rate = 0.0
for (med in meds) rate += ScheduleEngine.dailyConsumption(db.doseTimeDao().getForMed(med.med.id), today)
for (med in group) rate += ScheduleEngine.dailyConsumption(dtsByMed[med.med.id].orEmpty(), 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)}"
lowStock += "${item.name}: lager for ${ScheduleText.daysCount(days?.toInt() ?: 0)}"
}
if (ScheduleEngine.needsRenewal(item.rxExpiryEpochDay, today, RX_LEAD_DAYS)) {
val expired = item.rxExpiryEpochDay!! < today.toEpochDay()
@ -58,6 +69,8 @@ class SupplyCheckReceiver : BroadcastReceiver() {
}
}
for (med in meds) checkSeason(context, med.displayName, med.med.id, dtsByMed[med.med.id].orEmpty(), today)
// Post-or-cancel: cancelling when the condition clears is what lets the
// ongoing rx notification disappear by itself after a renewal.
if (lowStock.isNotEmpty()) {
@ -79,4 +92,55 @@ class SupplyCheckReceiver : BroadcastReceiver() {
Notifications.cancel(context, Notifications.RX_NOTIFICATION_ID)
}
}
/**
* Per-med seasonal nudge, post-or-cancel like the digests above:
* - resting and within [SEASON_START_LEAD_DAYS] of the next window "Start nå";
* - in season and within [SEASON_END_LEAD_DAYS] of its close "Forleng".
* Acting on either (or the season simply passing) clears it the next day.
*/
private fun checkSeason(
context: Context,
name: String,
medId: Long,
doseTimes: List<no.naiv.meddetsamme.data.DoseTime>,
today: LocalDate,
) {
val notifId = Notifications.seasonNotificationId(medId)
if (!ScheduleEngine.isSeasonal(doseTimes)) {
Notifications.cancel(context, notifId)
return
}
if (!ScheduleEngine.inSeasonNow(doseTimes, today)) {
val start = ScheduleEngine.nextSeasonStart(doseTimes, today)
val days = start?.let { ChronoUnit.DAYS.between(today, it) }
if (start != null && days != null && days in 0..SEASON_START_LEAD_DAYS) {
Notifications.post(
context, notifId,
Notifications.buildSeasonNotification(
context, medId, name,
"Sesongen nærmer seg (fra ${ScheduleText.dateLabel(start)}). Vil du starte nå?",
starting = true,
),
)
} else {
Notifications.cancel(context, notifId)
}
} else {
val end = ScheduleEngine.currentSeasonEnd(doseTimes, today)
val days = end?.let { ChronoUnit.DAYS.between(today, it) }
if (end != null && days != null && days in 0..SEASON_END_LEAD_DAYS) {
Notifications.post(
context, notifId,
Notifications.buildSeasonNotification(
context, medId, name,
"Sesongen er snart over (${ScheduleText.dateLabel(end)}). Fortsatt plaget? Forleng to uker.",
starting = false,
),
)
} else {
Notifications.cancel(context, notifId)
}
}
}
}