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:
parent
b4c929eca2
commit
d81a4fd926
15 changed files with 1098 additions and 103 deletions
|
|
@ -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 nå → 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue