med-det-samme/app/src/main/java/no/naiv/meddetsamme/alarm/SupplyCheckReceiver.kt
Ole-Morten Duesund 4bbb06d5d0 Season end nudge: post once per close, not daily for the lead window
SupplyCheckReceiver re-posted the auto-cancel "Forleng" card on every
daily check for the whole 5-day window, so a dismissed nudge came back
each morning. Remember the season-end epoch day nudged per med (plaintext
pref) and skip re-posting for the same close; a new end date (next year,
or after Forleng) still earns one fresh nudge.
2026-08-17 12:35:20 +02:00

153 lines
6.5 KiB
Kotlin

package no.naiv.meddetsamme.alarm
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
import no.naiv.meddetsamme.settings.SettingsStore
/**
* Daily derived-state check: low stock (inventory ÷ scheduled rate) and
* prescription renewal (rx expiry, independent of stock — decision #9).
* One digest notification, not one per med.
*/
class SupplyCheckReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val pending = goAsync()
CoroutineScope(Dispatchers.IO).launch {
try {
check(context)
} finally {
AlarmScheduler(context).armSupplyCheck() // chain the next daily check
pending.finish()
}
}
}
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) {
val db = MedDatabase.get(context)
val today = LocalDate.now()
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.
for ((_, group) in meds.groupBy { it.item.id }) {
val item = group.first().item
var rate = 0.0
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 ${ScheduleText.daysCount(days?.toInt() ?: 0)}"
}
if (ScheduleEngine.needsRenewal(item.rxExpiryEpochDay, today, RX_LEAD_DAYS)) {
val expired = item.rxExpiryEpochDay!! < today.toEpochDay()
rxRenewal += if (expired) "${item.name}: resept utløpt" else "${item.name}: resept må fornyes"
}
}
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()) {
Notifications.post(
context,
Notifications.SUPPLY_NOTIFICATION_ID,
Notifications.buildLowStockNotification(context, lowStock),
)
} else {
Notifications.cancel(context, Notifications.SUPPLY_NOTIFICATION_ID)
}
if (rxRenewal.isNotEmpty()) {
Notifications.post(
context,
Notifications.RX_NOTIFICATION_ID,
Notifications.buildRxNotification(context, rxRenewal),
)
} else {
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" (once per close).
* 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) {
// Once per season close, not daily: a dismissed card stays dismissed.
// Keyed on the end date, so "Forleng" (new end) earns one fresh nudge.
val settings = SettingsStore(context)
if (settings.seasonEndNudgedEpochDay(medId) != end.toEpochDay()) {
Notifications.post(
context, notifId,
Notifications.buildSeasonNotification(
context, medId, name,
"Sesongen er snart over (${ScheduleText.dateLabel(end)}). Fortsatt plaget? Forleng to uker.",
starting = false,
),
)
settings.setSeasonEndNudgedEpochDay(medId, end.toEpochDay())
}
} else {
Notifications.cancel(context, notifId)
}
}
}
}