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

@ -0,0 +1,66 @@
package no.naiv.meddetsamme.alarm
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import java.time.LocalDate
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import no.naiv.meddetsamme.data.MedDatabase
import no.naiv.meddetsamme.notify.Notifications
/**
* Handles the seasonal-nudge actions: a resting allergy med whose pollen season
* is starting early, or one whose season should run a little longer.
*
* Both write a one-year override onto the med's seasonal dose-times (decision:
* "overstyr årets vindu"). The overrides are absolute days, so the nominal
* month-day season is untouched and rules again next year see ScheduleEngine.
* - Start startEpochDay = today (the season begins now, ahead of schedule).
* - Forleng endEpochDay = today + [EXTEND_DAYS] (the season ends later).
*/
class SeasonActionReceiver : BroadcastReceiver() {
companion object {
const val ACTION_START_NOW = "no.naiv.meddetsamme.action.SEASON_START_NOW"
const val ACTION_EXTEND = "no.naiv.meddetsamme.action.SEASON_EXTEND"
const val EXTRA_MED_ID = "medId"
/** "Forleng" adds two weeks from today — the preview's "Forleng 2 uker". */
const val EXTEND_DAYS = 14L
}
override fun onReceive(context: Context, intent: Intent) {
val medId = intent.getLongExtra(EXTRA_MED_ID, -1)
if (medId < 0) return
val action = intent.action ?: return
val pending = goAsync()
CoroutineScope(Dispatchers.IO).launch {
try {
handle(context, action, medId)
} finally {
pending.finish()
}
}
}
private suspend fun handle(context: Context, action: String, medId: Long) {
val db = MedDatabase.get(context)
val today = LocalDate.now().toEpochDay()
val seasonal = db.doseTimeDao().getForMed(medId)
.filter { it.seasonStartMmdd > 0 && it.seasonEndMmdd > 0 }
for (dt in seasonal) {
val updated = when (action) {
ACTION_START_NOW -> dt.copy(startEpochDay = today)
ACTION_EXTEND -> dt.copy(endEpochDay = today + EXTEND_DAYS)
else -> return
}
db.doseTimeDao().update(updated)
}
// The nudge has been acted on; clear it and re-arm so the alarm picks up
// the now-active (or longer) window immediately.
Notifications.cancel(context, Notifications.seasonNotificationId(medId))
AlarmScheduler(context).armAll()
}
}

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