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
389
app/schemas/no.naiv.meddetsamme.data.MedDatabase/4.json
Normal file
389
app/schemas/no.naiv.meddetsamme.data.MedDatabase/4.json
Normal file
|
|
@ -0,0 +1,389 @@
|
|||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 4,
|
||||
"identityHash": "56932b6862c83744cc926f80ab7875e0",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "inventory_item",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `strength` TEXT NOT NULL, `unit` TEXT NOT NULL, `form` TEXT NOT NULL, `atcCode` TEXT, `packageSize` REAL, `stockUnits` REAL NOT NULL, `lowStockLeadDays` INTEGER NOT NULL, `rxExpiryEpochDay` INTEGER, `refillsRemaining` INTEGER)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "strength",
|
||||
"columnName": "strength",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "unit",
|
||||
"columnName": "unit",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "form",
|
||||
"columnName": "form",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "atcCode",
|
||||
"columnName": "atcCode",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "packageSize",
|
||||
"columnName": "packageSize",
|
||||
"affinity": "REAL"
|
||||
},
|
||||
{
|
||||
"fieldPath": "stockUnits",
|
||||
"columnName": "stockUnits",
|
||||
"affinity": "REAL",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lowStockLeadDays",
|
||||
"columnName": "lowStockLeadDays",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "rxExpiryEpochDay",
|
||||
"columnName": "rxExpiryEpochDay",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "refillsRemaining",
|
||||
"columnName": "refillsRemaining",
|
||||
"affinity": "INTEGER"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "medication",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `itemId` INTEGER NOT NULL, `withFood` INTEGER NOT NULL, `notes` TEXT NOT NULL, `active` INTEGER NOT NULL, FOREIGN KEY(`itemId`) REFERENCES `inventory_item`(`id`) ON UPDATE NO ACTION ON DELETE RESTRICT )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "itemId",
|
||||
"columnName": "itemId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "withFood",
|
||||
"columnName": "withFood",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notes",
|
||||
"columnName": "notes",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "active",
|
||||
"columnName": "active",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_medication_itemId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"itemId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_medication_itemId` ON `${TABLE_NAME}` (`itemId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "inventory_item",
|
||||
"onDelete": "RESTRICT",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"itemId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "dose_time",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `medId` INTEGER NOT NULL, `minuteOfDay` INTEGER NOT NULL, `amount` REAL NOT NULL, `daysOfWeekMask` INTEGER NOT NULL, `intervalDays` INTEGER NOT NULL, `anchorEpochDay` INTEGER, `cycleActiveDays` INTEGER NOT NULL DEFAULT 0, `cycleLengthDays` INTEGER NOT NULL DEFAULT 0, `startEpochDay` INTEGER, `endEpochDay` INTEGER, `seasonStartMmdd` INTEGER NOT NULL DEFAULT 0, `seasonEndMmdd` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`medId`) REFERENCES `medication`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "medId",
|
||||
"columnName": "medId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "minuteOfDay",
|
||||
"columnName": "minuteOfDay",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "amount",
|
||||
"columnName": "amount",
|
||||
"affinity": "REAL",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "daysOfWeekMask",
|
||||
"columnName": "daysOfWeekMask",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "intervalDays",
|
||||
"columnName": "intervalDays",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "anchorEpochDay",
|
||||
"columnName": "anchorEpochDay",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "cycleActiveDays",
|
||||
"columnName": "cycleActiveDays",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "cycleLengthDays",
|
||||
"columnName": "cycleLengthDays",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "startEpochDay",
|
||||
"columnName": "startEpochDay",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "endEpochDay",
|
||||
"columnName": "endEpochDay",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "seasonStartMmdd",
|
||||
"columnName": "seasonStartMmdd",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "seasonEndMmdd",
|
||||
"columnName": "seasonEndMmdd",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_dose_time_medId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"medId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_dose_time_medId` ON `${TABLE_NAME}` (`medId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "medication",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"medId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "dose_log",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `medId` INTEGER NOT NULL, `doseTimeId` INTEGER, `scheduledAtMillis` INTEGER NOT NULL, `amount` REAL NOT NULL, `status` TEXT NOT NULL, `actionedAtMillis` INTEGER, `nagCount` INTEGER NOT NULL, FOREIGN KEY(`medId`) REFERENCES `medication`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`doseTimeId`) REFERENCES `dose_time`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "medId",
|
||||
"columnName": "medId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "doseTimeId",
|
||||
"columnName": "doseTimeId",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "scheduledAtMillis",
|
||||
"columnName": "scheduledAtMillis",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "amount",
|
||||
"columnName": "amount",
|
||||
"affinity": "REAL",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "status",
|
||||
"columnName": "status",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "actionedAtMillis",
|
||||
"columnName": "actionedAtMillis",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "nagCount",
|
||||
"columnName": "nagCount",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_dose_log_medId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"medId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_dose_log_medId` ON `${TABLE_NAME}` (`medId`)"
|
||||
},
|
||||
{
|
||||
"name": "index_dose_log_doseTimeId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"doseTimeId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_dose_log_doseTimeId` ON `${TABLE_NAME}` (`doseTimeId`)"
|
||||
},
|
||||
{
|
||||
"name": "index_dose_log_doseTimeId_scheduledAtMillis",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"doseTimeId",
|
||||
"scheduledAtMillis"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_dose_log_doseTimeId_scheduledAtMillis` ON `${TABLE_NAME}` (`doseTimeId`, `scheduledAtMillis`)"
|
||||
},
|
||||
{
|
||||
"name": "index_dose_log_status",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"status"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_dose_log_status` ON `${TABLE_NAME}` (`status`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "medication",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"medId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
{
|
||||
"table": "dose_time",
|
||||
"onDelete": "SET NULL",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"doseTimeId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '56932b6862c83744cc926f80ab7875e0')"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -108,6 +108,35 @@ class MigrationTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun migrate3To4_addsSeasonColumnsDefaultingToNoSeason() {
|
||||
helper.createDatabase(TEST_DB, 3).use { db ->
|
||||
db.execSQL(
|
||||
"INSERT INTO inventory_item (id, name, strength, unit, form, stockUnits, " +
|
||||
"lowStockLeadDays) VALUES (1, 'Cetirizin', '10 mg', 'tablett', 'TABLET', 30.0, 7)",
|
||||
)
|
||||
db.execSQL("INSERT INTO medication (id, itemId, withFood, notes, active) VALUES (1, 1, 0, '', 1)")
|
||||
db.execSQL(
|
||||
"INSERT INTO dose_time (id, medId, minuteOfDay, amount, daysOfWeekMask, " +
|
||||
"intervalDays, anchorEpochDay, cycleActiveDays, cycleLengthDays) " +
|
||||
"VALUES (3, 1, 480, 1.0, 127, 0, NULL, 0, 0)",
|
||||
)
|
||||
}
|
||||
|
||||
// Validates the migrated schema against 4.json AND checks the new columns
|
||||
// default to "no season" while the existing row survives.
|
||||
helper.runMigrationsAndValidate(TEST_DB, 4, true, MedDatabase.MIGRATION_3_4).use { db ->
|
||||
db.query(
|
||||
"SELECT amount, seasonStartMmdd, seasonEndMmdd FROM dose_time WHERE id = 3",
|
||||
).use { c ->
|
||||
assertTrue(c.moveToFirst())
|
||||
assertEquals(1.0, c.getDouble(0), 0.0)
|
||||
assertEquals(0, c.getInt(1)) // seasonStartMmdd
|
||||
assertEquals(0, c.getInt(2)) // seasonEndMmdd
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TEST_DB = "migration-test.db"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@
|
|||
<receiver android:name=".alarm.DoseAlarmReceiver" android:exported="false" />
|
||||
<receiver android:name=".alarm.DoseActionReceiver" android:exported="false" />
|
||||
<receiver android:name=".alarm.SupplyCheckReceiver" android:exported="false" />
|
||||
<receiver android:name=".alarm.SeasonActionReceiver" android:exported="false" />
|
||||
|
||||
<!-- AlarmManager state dies with the OS process table; rebuild every alarm
|
||||
from the DB on boot and after app update. Both actions are protected
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -162,6 +162,9 @@ data class DoseTimeDto(
|
|||
val cycleLengthDays: Int = 0,
|
||||
val startEpochDay: Long? = null,
|
||||
val endEpochDay: Long? = null,
|
||||
// Added in schema v4; defaults keep older backup files parsing.
|
||||
val seasonStartMmdd: Int = 0,
|
||||
val seasonEndMmdd: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
|
@ -201,11 +204,13 @@ private fun MedicationDto.toEntity() = Medication(
|
|||
private fun DoseTime.toDto() = DoseTimeDto(
|
||||
id, medId, minuteOfDay, amount, daysOfWeekMask, intervalDays, anchorEpochDay,
|
||||
cycleActiveDays, cycleLengthDays, startEpochDay, endEpochDay,
|
||||
seasonStartMmdd, seasonEndMmdd,
|
||||
)
|
||||
|
||||
private fun DoseTimeDto.toEntity() = DoseTime(
|
||||
id, medId, minuteOfDay, amount, daysOfWeekMask, intervalDays, anchorEpochDay,
|
||||
cycleActiveDays, cycleLengthDays, startEpochDay, endEpochDay,
|
||||
seasonStartMmdd, seasonEndMmdd,
|
||||
)
|
||||
|
||||
private fun DoseLog.toDto() = DoseLogDto(id, medId, doseTimeId, scheduledAtMillis, amount, status.name, actionedAtMillis, nagCount)
|
||||
|
|
|
|||
|
|
@ -16,14 +16,19 @@ import androidx.room.PrimaryKey
|
|||
* - otherwise → weekly pattern from [daysOfWeekMask], bit 0 = Sunday … bit 6 =
|
||||
* Saturday. 0x7F = daily.
|
||||
*
|
||||
* Two orthogonal filters layer on top (the brief's "cyclic/taper extend from
|
||||
* Three orthogonal filters layer on top (the brief's "cyclic/taper extend from
|
||||
* the same model"):
|
||||
* - **Cyclic** ([cycleLengthDays] > 0): the dose only occurs in the first
|
||||
* [cycleActiveDays] of each [cycleLengthDays]-day cycle counted from
|
||||
* [anchorEpochDay] — e.g. 21-on/7-off contraception is 21/28.
|
||||
* - **Validity window** ([startEpochDay]/[endEpochDay], inclusive): the dose
|
||||
* only occurs within the date range. A taper is several rows with descending
|
||||
* [amount] and adjacent windows.
|
||||
* [amount] and adjacent windows. Also used as a one-year override of the
|
||||
* seasonal window (see [seasonStartMmdd]).
|
||||
* - **Seasonal window** ([seasonStartMmdd]/[seasonEndMmdd]): a calendar window
|
||||
* that repeats *every year* — e.g. an allergy med for the pollen season.
|
||||
* Encoded as month×100+day (0301 = 1 Mar). Both 0 = no season. A start after
|
||||
* the end wraps the new year (e.g. a winter season Nov→Feb).
|
||||
*/
|
||||
@Entity(
|
||||
tableName = "dose_time",
|
||||
|
|
@ -60,4 +65,8 @@ data class DoseTime(
|
|||
val startEpochDay: Long? = null,
|
||||
/** Inclusive validity-window end; null = no upper bound. Taper rows set this. */
|
||||
val endEpochDay: Long? = null,
|
||||
/** Yearly season start as month×100+day (0301 = 1 Mar). 0 = no season. */
|
||||
@ColumnInfo(defaultValue = "0") val seasonStartMmdd: Int = 0,
|
||||
/** Yearly season end as month×100+day (0531 = 31 May). 0 = no season. */
|
||||
@ColumnInfo(defaultValue = "0") val seasonEndMmdd: Int = 0,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
|||
*/
|
||||
@Database(
|
||||
entities = [InventoryItem::class, Medication::class, DoseTime::class, DoseLog::class],
|
||||
version = 3,
|
||||
version = 4,
|
||||
exportSchema = true,
|
||||
)
|
||||
abstract class MedDatabase : RoomDatabase() {
|
||||
|
|
@ -92,13 +92,24 @@ abstract class MedDatabase : RoomDatabase() {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v3 → v4: yearly seasonal-window columns on dose_time. Additive ALTERs
|
||||
* defaulting to 0 ("no season") — existing rows keep behaving as before.
|
||||
*/
|
||||
val MIGRATION_3_4 = object : Migration(3, 4) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE dose_time ADD COLUMN `seasonStartMmdd` INTEGER NOT NULL DEFAULT 0")
|
||||
db.execSQL("ALTER TABLE dose_time ADD COLUMN `seasonEndMmdd` INTEGER NOT NULL DEFAULT 0")
|
||||
}
|
||||
}
|
||||
|
||||
fun get(context: Context): MedDatabase =
|
||||
instance ?: synchronized(this) {
|
||||
instance ?: Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
MedDatabase::class.java,
|
||||
"med-det-samme.db",
|
||||
).addMigrations(MIGRATION_1_2, MIGRATION_2_3).build().also { instance = it }
|
||||
).addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4).build().also { instance = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,8 +20,9 @@ import no.naiv.meddetsamme.data.DoseTime
|
|||
* - 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.
|
||||
* - cyclic ([cycleLengthDays] > 0) and validity window ([startEpochDay]/
|
||||
* [endEpochDay]) further restrict the above — see [occursOn].
|
||||
* - cyclic ([cycleLengthDays] > 0), validity window ([startEpochDay]/
|
||||
* [endEpochDay]) and yearly season ([seasonStartMmdd]/[seasonEndMmdd])
|
||||
* further restrict the above — see [occursOn].
|
||||
*/
|
||||
object ScheduleEngine {
|
||||
|
||||
|
|
@ -53,14 +54,64 @@ object ScheduleEngine {
|
|||
return Math.floorMod(day - anchor, length.toLong()) < active
|
||||
}
|
||||
|
||||
/** Does this dose-time occur on [date]? Base rule ∧ validity window ∧ cycle. */
|
||||
fun occursOn(doseTime: DoseTime, date: LocalDate): Boolean {
|
||||
val day = date.toEpochDay()
|
||||
doseTime.startEpochDay?.let { if (day < it) return false }
|
||||
doseTime.endEpochDay?.let { if (day > it) return false }
|
||||
return baseOccursOn(doseTime, date) && inCycleWindow(doseTime, date)
|
||||
private fun isSeasonal(doseTime: DoseTime): Boolean =
|
||||
doseTime.seasonStartMmdd > 0 && doseTime.seasonEndMmdd > 0
|
||||
|
||||
/**
|
||||
* Is [date] inside this dose-time's *nominal* yearly season window? A season
|
||||
* is a pair of month×100+day marks that repeat every year. A start after the
|
||||
* end wraps the new year, e.g. a winter season 1101→0228 is in season in
|
||||
* December *and* in February. Assumes the row is seasonal (caller checks).
|
||||
*/
|
||||
private fun inSeason(doseTime: DoseTime, date: LocalDate): Boolean {
|
||||
val start = doseTime.seasonStartMmdd
|
||||
val end = doseTime.seasonEndMmdd
|
||||
val mmdd = date.monthValue * 100 + date.dayOfMonth
|
||||
return if (start <= end) mmdd in start..end else mmdd >= start || mmdd <= end
|
||||
}
|
||||
|
||||
/** The month×100+day mark as a real date in [year] (leap-safe: 0229 → 28 Feb off-years). */
|
||||
private fun mmddToDate(mmdd: Int, year: Int): LocalDate =
|
||||
java.time.MonthDay.of(mmdd / 100, mmdd % 100).atYear(year)
|
||||
|
||||
/**
|
||||
* Whether [date] falls in the date bounds of this dose-time. For a **taper /
|
||||
* course** row [startEpochDay]/[endEpochDay] are hard bounds (a finite
|
||||
* window). For a **seasonal** row they instead *extend* the current year's
|
||||
* season: [startEpochDay] pulls the start earlier ("Start nå"), [endEpochDay]
|
||||
* pushes the end later ("Forleng"). Because the overrides are absolute days,
|
||||
* they apply to exactly one season instance and are ignored in later years.
|
||||
*/
|
||||
private fun withinDateBounds(doseTime: DoseTime, date: LocalDate): Boolean {
|
||||
if (!isSeasonal(doseTime)) {
|
||||
val day = date.toEpochDay()
|
||||
if (doseTime.startEpochDay != null && day < doseTime.startEpochDay) return false
|
||||
if (doseTime.endEpochDay != null && day > doseTime.endEpochDay) return false
|
||||
return true
|
||||
}
|
||||
if (inSeason(doseTime, date)) return true
|
||||
val day = date.toEpochDay()
|
||||
// Early-start bridge: [override start, this year's nominal start) — inSeason continues from there.
|
||||
doseTime.startEpochDay?.let { os ->
|
||||
val d = LocalDate.ofEpochDay(os)
|
||||
var nominalStart = mmddToDate(doseTime.seasonStartMmdd, d.year)
|
||||
if (nominalStart.toEpochDay() < os) nominalStart = mmddToDate(doseTime.seasonStartMmdd, d.year + 1)
|
||||
if (day in os until nominalStart.toEpochDay()) return true
|
||||
}
|
||||
// Extend bridge: (this year's nominal end, override end] — inSeason covers up to the end.
|
||||
doseTime.endEpochDay?.let { oe ->
|
||||
val d = LocalDate.ofEpochDay(oe)
|
||||
var nominalEnd = mmddToDate(doseTime.seasonEndMmdd, d.year)
|
||||
if (nominalEnd.toEpochDay() > oe) nominalEnd = mmddToDate(doseTime.seasonEndMmdd, d.year - 1)
|
||||
if (day in (nominalEnd.toEpochDay() + 1)..oe) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Does this dose-time occur on [date]? Date bounds ∧ base rule ∧ cycle. */
|
||||
fun occursOn(doseTime: DoseTime, date: LocalDate): Boolean =
|
||||
withinDateBounds(doseTime, date) && baseOccursOn(doseTime, date) && inCycleWindow(doseTime, date)
|
||||
|
||||
/**
|
||||
* Earliest occurrence strictly after [after], or null if the schedule never
|
||||
* fires within the search horizon (empty mask / missing anchor / window
|
||||
|
|
@ -82,6 +133,43 @@ object ScheduleEngine {
|
|||
return null
|
||||
}
|
||||
|
||||
/** True if any row is bounded to a yearly season window (an allergy med, say). */
|
||||
fun isSeasonal(doseTimes: List<DoseTime>): Boolean = doseTimes.any { isSeasonal(it) }
|
||||
|
||||
/** Is any seasonal row of this med active on [today] (nominal window or an override)? */
|
||||
fun inSeasonNow(doseTimes: List<DoseTime>, today: LocalDate): Boolean =
|
||||
doseTimes.any { isSeasonal(it) && withinDateBounds(it, today) }
|
||||
|
||||
/**
|
||||
* The date a resting seasonal med next enters its window — the season start,
|
||||
* not the first dose (which the weekday mask could push later). Null if the
|
||||
* med isn't seasonal or never re-opens within the horizon. Scans the tested
|
||||
* [withinDateBounds] so override/wrap behaviour stays in one place.
|
||||
*/
|
||||
fun nextSeasonStart(doseTimes: List<DoseTime>, today: LocalDate): LocalDate? {
|
||||
val rows = doseTimes.filter { isSeasonal(it) }
|
||||
if (rows.isEmpty()) return null
|
||||
var date = today
|
||||
repeat(SEARCH_HORIZON_DAYS) {
|
||||
if (rows.any { withinDateBounds(it, date) }) return date
|
||||
date = date.plusDays(1)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** The last date of the in-season stretch [today] sits in, or null if resting. */
|
||||
fun currentSeasonEnd(doseTimes: List<DoseTime>, today: LocalDate): LocalDate? {
|
||||
val active = doseTimes.filter { isSeasonal(it) && withinDateBounds(it, today) }
|
||||
if (active.isEmpty()) return null
|
||||
var last = today
|
||||
repeat(SEARCH_HORIZON_DAYS) {
|
||||
val next = last.plusDays(1)
|
||||
if (active.none { withinDateBounds(it, next) }) return last
|
||||
last = next
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
/** 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
|
||||
|
|
@ -103,10 +191,10 @@ object ScheduleEngine {
|
|||
* not historical, because days-of-supply is a forward prediction.
|
||||
*/
|
||||
fun dailyConsumption(doseTimes: List<DoseTime>, today: LocalDate): Double {
|
||||
val day = today.toEpochDay()
|
||||
return doseTimes.sumOf { dt ->
|
||||
if (dt.startEpochDay != null && day < dt.startEpochDay) return@sumOf 0.0
|
||||
if (dt.endEpochDay != null && day > dt.endEpochDay) return@sumOf 0.0
|
||||
// One predicate for taper windows AND seasonal windows: off-window rows
|
||||
// (an elapsed taper step, an out-of-season allergy med) consume nothing.
|
||||
if (!withinDateBounds(dt, today)) return@sumOf 0.0
|
||||
var rate = if (dt.intervalDays > 1) {
|
||||
if (dt.anchorEpochDay == null) 0.0 else dt.amount / dt.intervalDays
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ object ScheduleText {
|
|||
|
||||
fun describe(doseTime: DoseTime): String =
|
||||
"${"%02d:%02d".format(doseTime.minuteOfDay / 60, doseTime.minuteOfDay % 60)} " +
|
||||
"${daysText(doseTime)}${cycleText(doseTime)}${windowText(doseTime)}"
|
||||
"${daysText(doseTime)}${cycleText(doseTime)}${windowText(doseTime)}${seasonText(doseTime)}"
|
||||
|
||||
/** " · syklus 21/28" when cyclic, else "". */
|
||||
fun cycleText(doseTime: DoseTime): String {
|
||||
|
|
@ -41,6 +41,21 @@ object ScheduleText {
|
|||
}
|
||||
}
|
||||
|
||||
/** " · sesong 1. mar–31. mai" for a yearly seasonal window (month×100+day). */
|
||||
fun seasonText(doseTime: DoseTime): String {
|
||||
val start = doseTime.seasonStartMmdd
|
||||
val end = doseTime.seasonEndMmdd
|
||||
if (start <= 0 || end <= 0) return ""
|
||||
return " · sesong ${mmddText(start)}–${mmddText(end)}"
|
||||
}
|
||||
|
||||
/** Formats a month×100+day mark as "1. mar". A leap-year-safe MonthDay so 0229 renders. */
|
||||
private fun mmddText(mmdd: Int): String =
|
||||
java.time.MonthDay.of(mmdd / 100, mmdd % 100).format(dateFmt)
|
||||
|
||||
/** A single date as "1. mar" (nb-pinned), e.g. for "next season starts …". */
|
||||
fun dateLabel(date: LocalDate): String = dateFmt.format(date)
|
||||
|
||||
fun daysText(doseTime: DoseTime): String {
|
||||
if (doseTime.intervalDays > 1) {
|
||||
return "(hver ${doseTime.intervalDays}. dag)"
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import androidx.core.app.NotificationManagerCompat
|
|||
import no.naiv.meddetsamme.MainActivity
|
||||
import no.naiv.meddetsamme.R
|
||||
import no.naiv.meddetsamme.alarm.DoseActionReceiver
|
||||
import no.naiv.meddetsamme.alarm.SeasonActionReceiver
|
||||
import no.naiv.meddetsamme.data.DoseLog
|
||||
import no.naiv.meddetsamme.data.MedWithItem
|
||||
import java.time.Instant
|
||||
|
|
@ -28,6 +29,9 @@ object Notifications {
|
|||
const val SUPPLY_NOTIFICATION_ID = 1_000_000_007
|
||||
const val RX_NOTIFICATION_ID = 1_000_000_009
|
||||
|
||||
/** One season nudge per med, so its action button can target that med. */
|
||||
fun seasonNotificationId(medId: Long): Int = (2_000_000_000L + medId).toInt()
|
||||
|
||||
private val timeFmt = DateTimeFormatter.ofPattern("HH:mm")
|
||||
|
||||
fun createChannels(context: Context) {
|
||||
|
|
@ -117,6 +121,48 @@ object Notifications {
|
|||
.setOngoing(true)
|
||||
.build()
|
||||
|
||||
/**
|
||||
* Seasonal nudge for one med. [starting] = true is the pre-season "Start nå"
|
||||
* card; false is the end-of-season "Forleng" card. Posted on the supply
|
||||
* channel and dismissable — acting on it (or the season simply passing)
|
||||
* cancels it on the next daily check.
|
||||
*/
|
||||
fun buildSeasonNotification(
|
||||
context: Context,
|
||||
medId: Long,
|
||||
title: String,
|
||||
body: String,
|
||||
starting: Boolean,
|
||||
): Notification {
|
||||
val notifId = seasonNotificationId(medId)
|
||||
val action = if (starting) SeasonActionReceiver.ACTION_START_NOW else SeasonActionReceiver.ACTION_EXTEND
|
||||
val actionLabel = if (starting) "Start nå" else "Forleng 2 uker"
|
||||
val actionIntent = PendingIntent.getBroadcast(
|
||||
context,
|
||||
notifId,
|
||||
Intent(context, SeasonActionReceiver::class.java)
|
||||
.setAction(action)
|
||||
.putExtra(SeasonActionReceiver.EXTRA_MED_ID, medId),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
val contentIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
notifId,
|
||||
Intent(context, MainActivity::class.java),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
return NotificationCompat.Builder(context, CHANNEL_SUPPLY)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(title)
|
||||
.setContentText(body)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
|
||||
.setCategory(NotificationCompat.CATEGORY_REMINDER)
|
||||
.setContentIntent(contentIntent)
|
||||
.setAutoCancel(true)
|
||||
.addAction(0, actionLabel, actionIntent)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun supplyDigestBuilder(
|
||||
context: Context,
|
||||
requestCode: Int,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
package no.naiv.meddetsamme.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
|
|
@ -8,6 +10,7 @@ import androidx.compose.foundation.layout.Row
|
|||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
|
|
@ -43,10 +46,13 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import java.time.LocalDate
|
||||
import kotlinx.coroutines.launch
|
||||
|
|
@ -456,81 +462,131 @@ private fun DoseTimeEditorDialog(
|
|||
var offDays by remember {
|
||||
mutableStateOf((if (existingCyclic) existing!!.cycleLengthDays - existing.cycleActiveDays else 7).toString())
|
||||
}
|
||||
// Yearly season (e.g. pollen): both marks > 0 means seasonal. Default Mar–May.
|
||||
val existingSeasonal = (existing?.seasonStartMmdd ?: 0) > 0 && (existing?.seasonEndMmdd ?: 0) > 0
|
||||
var seasonal by remember { mutableStateOf(existingSeasonal) }
|
||||
var startMonth by remember { mutableStateOf(if (existingSeasonal) existing!!.seasonStartMmdd / 100 else 3) }
|
||||
var startDay by remember { mutableStateOf((if (existingSeasonal) existing!!.seasonStartMmdd % 100 else 1).toString()) }
|
||||
var endMonth by remember { mutableStateOf(if (existingSeasonal) existing!!.seasonEndMmdd / 100 else 5) }
|
||||
var endDay by remember { mutableStateOf((if (existingSeasonal) existing!!.seasonEndMmdd % 100 else 31).toString()) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(if (existing == null) "Ny doseringstid" else "Rediger doseringstid") },
|
||||
text = {
|
||||
// Scrollable: the dialog now has time + amount + mode + cycle and would
|
||||
// otherwise overflow on small screens.
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
TimePicker(state = timeState)
|
||||
OutlinedTextField(
|
||||
value = amount, onValueChange = { amount = it },
|
||||
label = { Text("Mengde per dose") }, singleLine = true,
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
FilterChip(
|
||||
selected = !intervalMode, onClick = { intervalMode = false },
|
||||
label = { Text("Ukedager") },
|
||||
)
|
||||
FilterChip(
|
||||
selected = intervalMode, onClick = { intervalMode = true },
|
||||
label = { Text("Hver N. dag") },
|
||||
)
|
||||
}
|
||||
if (intervalMode) {
|
||||
OutlinedTextField(
|
||||
value = intervalDays, onValueChange = { intervalDays = it },
|
||||
label = { Text("Antall dager mellom doser") }, singleLine = true,
|
||||
)
|
||||
} else {
|
||||
// Monday-first toggles; bit 0 = Sunday underneath. FlowRow wraps
|
||||
// so the whole week is visible — a plain Row overflowed the dialog.
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
listOf(1 to "man", 2 to "tir", 3 to "ons", 4 to "tor", 5 to "fre", 6 to "lør", 0 to "søn")
|
||||
.forEach { (bit, label) ->
|
||||
val selected = mask and (1 shl bit) != 0
|
||||
FilterChip(
|
||||
selected = selected,
|
||||
onClick = { mask = mask xor (1 shl bit) },
|
||||
label = { Text(label) },
|
||||
modifier = Modifier.semantics {
|
||||
contentDescription = dayName(bit) + if (selected) ", valgt" else ", ikke valgt"
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.clickable { cyclic = !cyclic },
|
||||
// Scrollable: the dialog now has time + amount + mode + cycle + season
|
||||
// and overflows on most screens. The time picker fills the first view,
|
||||
// so cyclic/season sit below the fold — hence the "scroll for more" hint.
|
||||
val schedScroll = rememberScrollState()
|
||||
Box {
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(schedScroll),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Checkbox(checked = cyclic, onCheckedChange = { cyclic = it })
|
||||
Text("Syklisk (på/av-perioder)")
|
||||
}
|
||||
if (cyclic) {
|
||||
TimePicker(state = timeState)
|
||||
OutlinedTextField(
|
||||
value = amount, onValueChange = { amount = it },
|
||||
label = { Text("Mengde per dose") }, singleLine = true,
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = onDays, onValueChange = { onDays = it },
|
||||
label = { Text("Dager på") }, singleLine = true, modifier = Modifier.weight(1f),
|
||||
FilterChip(
|
||||
selected = !intervalMode, onClick = { intervalMode = false },
|
||||
label = { Text("Ukedager") },
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = offDays, onValueChange = { offDays = it },
|
||||
label = { Text("Dager av") }, singleLine = true, modifier = Modifier.weight(1f),
|
||||
FilterChip(
|
||||
selected = intervalMode, onClick = { intervalMode = true },
|
||||
label = { Text("Hver N. dag") },
|
||||
)
|
||||
}
|
||||
if (intervalMode) {
|
||||
OutlinedTextField(
|
||||
value = intervalDays, onValueChange = { intervalDays = it },
|
||||
label = { Text("Antall dager mellom doser") }, singleLine = true,
|
||||
)
|
||||
} else {
|
||||
// Monday-first toggles; bit 0 = Sunday underneath. FlowRow wraps
|
||||
// so the whole week is visible — a plain Row overflowed the dialog.
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
listOf(1 to "man", 2 to "tir", 3 to "ons", 4 to "tor", 5 to "fre", 6 to "lør", 0 to "søn")
|
||||
.forEach { (bit, label) ->
|
||||
val selected = mask and (1 shl bit) != 0
|
||||
FilterChip(
|
||||
selected = selected,
|
||||
onClick = { mask = mask xor (1 shl bit) },
|
||||
label = { Text(label) },
|
||||
modifier = Modifier.semantics {
|
||||
contentDescription = dayName(bit) + if (selected) ", valgt" else ", ikke valgt"
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.clickable { cyclic = !cyclic },
|
||||
) {
|
||||
Checkbox(checked = cyclic, onCheckedChange = { cyclic = it })
|
||||
Text("Syklisk (på/av-perioder)")
|
||||
}
|
||||
if (cyclic) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = onDays, onValueChange = { onDays = it },
|
||||
label = { Text("Dager på") }, singleLine = true, modifier = Modifier.weight(1f),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = offDays, onValueChange = { offDays = it },
|
||||
label = { Text("Dager av") }, singleLine = true, modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
"F.eks. 21 på / 7 av. Syklusen starter i dag.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.clickable { seasonal = !seasonal },
|
||||
) {
|
||||
Checkbox(checked = seasonal, onCheckedChange = { seasonal = it })
|
||||
Text("Sesong (samme periode hvert år)")
|
||||
}
|
||||
if (seasonal) {
|
||||
MonthDayRow("Fra", startMonth, startDay, { startMonth = it }, { startDay = it })
|
||||
MonthDayRow("Til", endMonth, endDay, { endMonth = it }, { endDay = it })
|
||||
Text(
|
||||
"F.eks. pollensesong mars–mai. Doseringen hviler utenom sesong " +
|
||||
"og gjentas automatisk hvert år.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
// While anything is still below the fold, name what's down there so
|
||||
// cyclic/season are discoverable; clears itself at the bottom.
|
||||
if (schedScroll.canScrollForward) {
|
||||
Text(
|
||||
"F.eks. 21 på / 7 av. Syklusen starter i dag.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
"⌄ Bla ned for syklus og sesong",
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
0f to Color.Transparent,
|
||||
1f to MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
),
|
||||
)
|
||||
.padding(top = 18.dp, bottom = 4.dp),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -544,6 +600,12 @@ private fun DoseTimeEditorDialog(
|
|||
val cycleActive = if (cyclic && on > 0 && off > 0) on else 0
|
||||
val cycleLength = if (cycleActive > 0) on + off else 0
|
||||
val needsAnchor = intervalMode || cycleActive > 0
|
||||
// Clamp the day to the month's max (leap-safe: Feb → 29) so we never
|
||||
// store an impossible MonthDay that would crash the renderer.
|
||||
fun mmdd(month: Int, dayStr: String): Int {
|
||||
val day = (dayStr.toIntOrNull() ?: 1).coerceIn(1, java.time.Month.of(month).maxLength())
|
||||
return month * 100 + day
|
||||
}
|
||||
val dt = DoseTime(
|
||||
id = existing?.id ?: 0,
|
||||
medId = medId,
|
||||
|
|
@ -558,6 +620,8 @@ private fun DoseTimeEditorDialog(
|
|||
cycleLengthDays = cycleLength,
|
||||
startEpochDay = existing?.startEpochDay,
|
||||
endEpochDay = existing?.endEpochDay,
|
||||
seasonStartMmdd = if (seasonal) mmdd(startMonth, startDay) else 0,
|
||||
seasonEndMmdd = if (seasonal) mmdd(endMonth, endDay) else 0,
|
||||
)
|
||||
scope.launch {
|
||||
if (existing == null) db.doseTimeDao().insert(dt) else db.doseTimeDao().update(dt)
|
||||
|
|
@ -571,3 +635,50 @@ private fun DoseTimeEditorDialog(
|
|||
|
||||
private fun dayName(bit: Int): String =
|
||||
listOf("søndag", "mandag", "tirsdag", "onsdag", "torsdag", "fredag", "lørdag")[bit]
|
||||
|
||||
private val MONTH_NAMES = listOf(
|
||||
"januar", "februar", "mars", "april", "mai", "juni",
|
||||
"juli", "august", "september", "oktober", "november", "desember",
|
||||
)
|
||||
|
||||
/** Month dropdown + day field for one end of a yearly season window. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun MonthDayRow(
|
||||
label: String,
|
||||
month: Int,
|
||||
day: String,
|
||||
onMonth: (Int) -> Unit,
|
||||
onDay: (String) -> Unit,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(label, modifier = Modifier.width(36.dp))
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = it },
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = MONTH_NAMES[month - 1],
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Måned") },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) },
|
||||
modifier = Modifier.fillMaxWidth().menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
|
||||
)
|
||||
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
MONTH_NAMES.forEachIndexed { i, name ->
|
||||
DropdownMenuItem(text = { Text(name) }, onClick = { onMonth(i + 1); expanded = false })
|
||||
}
|
||||
}
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = day,
|
||||
onValueChange = onDay,
|
||||
label = { Text("Dag") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.width(88.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,10 @@ import androidx.compose.material3.TopAppBar
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
|
@ -29,8 +32,12 @@ import androidx.compose.ui.res.painterResource
|
|||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import java.time.LocalDate
|
||||
import no.naiv.meddetsamme.R
|
||||
import no.naiv.meddetsamme.data.DoseTime
|
||||
import no.naiv.meddetsamme.data.MedDatabase
|
||||
import no.naiv.meddetsamme.data.MedWithItem
|
||||
import no.naiv.meddetsamme.domain.ScheduleEngine
|
||||
import no.naiv.meddetsamme.domain.ScheduleText
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
|
|
@ -40,6 +47,20 @@ fun MedListScreen(nav: Nav) {
|
|||
val db = remember { MedDatabase.get(context) }
|
||||
val meds by db.medicationDao().observeActive().collectAsState(initial = emptyList())
|
||||
|
||||
// Dose times aren't on MedWithItem, but we need them to tell a resting
|
||||
// seasonal med (out of its yearly window) from a normally-active one.
|
||||
var doseTimesByMed by remember { mutableStateOf<Map<Long, List<DoseTime>>>(emptyMap()) }
|
||||
LaunchedEffect(meds) {
|
||||
doseTimesByMed = meds.associate { it.med.id to db.doseTimeDao().getForMed(it.med.id) }
|
||||
}
|
||||
val today = LocalDate.now()
|
||||
val restingIds = meds.filter { m ->
|
||||
val dts = doseTimesByMed[m.med.id].orEmpty()
|
||||
ScheduleEngine.isSeasonal(dts) && !ScheduleEngine.inSeasonNow(dts, today)
|
||||
}.map { it.med.id }.toSet()
|
||||
val activeMeds = meds.filterNot { it.med.id in restingIds }
|
||||
val restingMeds = meds.filter { it.med.id in restingIds }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
|
|
@ -73,29 +94,61 @@ fun MedListScreen(nav: Nav) {
|
|||
)
|
||||
}
|
||||
}
|
||||
items(meds, key = { it.med.id }) { med ->
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { nav.push(Screen.MedEdit(med.med.id)) }
|
||||
.semantics(mergeDescendants = true) {
|
||||
contentDescription =
|
||||
"${med.displayName}, ${FORM_LABELS[med.item.form]}, lager ${ScheduleText.amountText(med.item.stockUnits, med.item.unit)}. Trykk for å redigere."
|
||||
},
|
||||
) {
|
||||
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
FormAvatar(med.item.form)
|
||||
Column(Modifier.padding(start = 12.dp).weight(1f)) {
|
||||
Text(med.displayName, style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
"Lager: ${ScheduleText.amountText(med.item.stockUnits, med.item.unit)}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
items(activeMeds, key = { it.med.id }) { med ->
|
||||
MedCard(
|
||||
med = med,
|
||||
subtitle = "Lager: ${ScheduleText.amountText(med.item.stockUnits, med.item.unit)}",
|
||||
onClick = { nav.push(Screen.MedEdit(med.med.id)) },
|
||||
)
|
||||
}
|
||||
|
||||
if (restingMeds.isNotEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
"Sesong (hviler)",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 16.dp, bottom = 4.dp),
|
||||
)
|
||||
}
|
||||
items(restingMeds, key = { it.med.id }) { med ->
|
||||
val nextStart = ScheduleEngine.nextSeasonStart(doseTimesByMed[med.med.id].orEmpty(), today)
|
||||
val subtitle = nextStart
|
||||
?.let { "Hviler · neste sesong fra ${ScheduleText.dateLabel(it)}" }
|
||||
?: "Hviler utenom sesong"
|
||||
MedCard(
|
||||
med = med,
|
||||
subtitle = subtitle,
|
||||
onClick = { nav.push(Screen.MedEdit(med.med.id)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One medication row: form avatar, name, and a one-line subtitle. */
|
||||
@Composable
|
||||
private fun MedCard(med: MedWithItem, subtitle: String, onClick: () -> Unit) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.semantics(mergeDescendants = true) {
|
||||
contentDescription =
|
||||
"${med.displayName}, ${FORM_LABELS[med.item.form]}, $subtitle. Trykk for å redigere."
|
||||
},
|
||||
) {
|
||||
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
FormAvatar(med.item.form)
|
||||
Column(Modifier.padding(start = 12.dp).weight(1f)) {
|
||||
Text(med.displayName, style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
subtitle,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ class ScheduleEngineTest {
|
|||
cycleLengthDays: Int = 0,
|
||||
startEpochDay: Long? = null,
|
||||
endEpochDay: Long? = null,
|
||||
seasonStartMmdd: Int = 0,
|
||||
seasonEndMmdd: Int = 0,
|
||||
id: Long = 1,
|
||||
) = DoseTime(
|
||||
id = id,
|
||||
|
|
@ -45,6 +47,8 @@ class ScheduleEngineTest {
|
|||
cycleLengthDays = cycleLengthDays,
|
||||
startEpochDay = startEpochDay,
|
||||
endEpochDay = endEpochDay,
|
||||
seasonStartMmdd = seasonStartMmdd,
|
||||
seasonEndMmdd = seasonEndMmdd,
|
||||
)
|
||||
|
||||
private fun at(date: LocalDate, hour: Int, minute: Int = 0): LocalDateTime =
|
||||
|
|
@ -222,6 +226,98 @@ class ScheduleEngineTest {
|
|||
assertTrue(ScheduleEngine.occurrencesOn(rows, wednesday.plusDays(6)).isEmpty())
|
||||
}
|
||||
|
||||
// ---- yearly season (allergy / pollen) --------------------------------
|
||||
|
||||
@Test
|
||||
fun `season bounds occurrences within the calendar window each year`() {
|
||||
// Pollen season 1 Mar – 31 May, daily dose.
|
||||
val dt = doseTime(seasonStartMmdd = 301, seasonEndMmdd = 531)
|
||||
assertFalse(ScheduleEngine.occursOn(dt, LocalDate.of(2026, 2, 28))) // day before
|
||||
assertTrue(ScheduleEngine.occursOn(dt, LocalDate.of(2026, 3, 1))) // start, inclusive
|
||||
assertTrue(ScheduleEngine.occursOn(dt, LocalDate.of(2026, 4, 15)))
|
||||
assertTrue(ScheduleEngine.occursOn(dt, LocalDate.of(2026, 5, 31))) // end, inclusive
|
||||
assertFalse(ScheduleEngine.occursOn(dt, LocalDate.of(2026, 6, 1))) // day after
|
||||
// …and it repeats the next year, no edit needed.
|
||||
assertTrue(ScheduleEngine.occursOn(dt, LocalDate.of(2027, 4, 15)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nextOccurrence off-season jumps to next season start`() {
|
||||
val dt = doseTime(seasonStartMmdd = 301, seasonEndMmdd = 531)
|
||||
// From 10 June 2026 (out of season) the next dose is 1 Mar 2027 at 08:00.
|
||||
val next = ScheduleEngine.nextOccurrence(dt, at(wednesday, 12, 0))
|
||||
assertEquals(at(LocalDate.of(2027, 3, 1), 8), next)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `season wrapping the new year is in season on both sides of december`() {
|
||||
// Winter season 1 Nov – 28 Feb (start > end → wraps).
|
||||
val dt = doseTime(seasonStartMmdd = 1101, seasonEndMmdd = 228)
|
||||
assertTrue(ScheduleEngine.occursOn(dt, LocalDate.of(2026, 12, 15)))
|
||||
assertTrue(ScheduleEngine.occursOn(dt, LocalDate.of(2027, 1, 10)))
|
||||
assertTrue(ScheduleEngine.occursOn(dt, LocalDate.of(2027, 2, 28)))
|
||||
assertFalse(ScheduleEngine.occursOn(dt, LocalDate.of(2026, 7, 1)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `season comparison is calendar-correct across month boundaries`() {
|
||||
// 29 Feb (a real leap day) sits inside a Feb–Mar season without special-casing.
|
||||
val dt = doseTime(seasonStartMmdd = 201, seasonEndMmdd = 331)
|
||||
assertTrue(ScheduleEngine.occursOn(dt, LocalDate.of(2028, 2, 29)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `daily consumption is the active rate in season and zero out of season`() {
|
||||
val dt = doseTime(amount = 1.0, seasonStartMmdd = 301, seasonEndMmdd = 531)
|
||||
assertEquals(1.0, ScheduleEngine.dailyConsumption(listOf(dt), LocalDate.of(2026, 4, 15)), 1e-9)
|
||||
assertEquals(0.0, ScheduleEngine.dailyConsumption(listOf(dt), wednesday), 1e-9) // June, off-season
|
||||
}
|
||||
|
||||
// ---- seasonal one-year overrides ("Start nå" / "Forleng") ------------
|
||||
|
||||
@Test
|
||||
fun `early-start override pulls this year's season earlier then expires`() {
|
||||
// Season Mar–May; "Start nå" on 25 Feb 2026 sets startEpochDay there.
|
||||
val dt = doseTime(
|
||||
seasonStartMmdd = 301, seasonEndMmdd = 531,
|
||||
startEpochDay = LocalDate.of(2026, 2, 25).toEpochDay(),
|
||||
)
|
||||
assertFalse(ScheduleEngine.occursOn(dt, LocalDate.of(2026, 2, 24))) // before override
|
||||
assertTrue(ScheduleEngine.occursOn(dt, LocalDate.of(2026, 2, 25))) // override start
|
||||
assertTrue(ScheduleEngine.occursOn(dt, LocalDate.of(2026, 2, 28))) // bridge to nominal
|
||||
assertTrue(ScheduleEngine.occursOn(dt, LocalDate.of(2026, 3, 1))) // nominal start
|
||||
assertTrue(ScheduleEngine.occursOn(dt, LocalDate.of(2026, 5, 31))) // nominal end
|
||||
assertFalse(ScheduleEngine.occursOn(dt, LocalDate.of(2026, 6, 1))) // no extend
|
||||
// Next year: the absolute override is in the past, so the nominal window rules again.
|
||||
assertFalse(ScheduleEngine.occursOn(dt, LocalDate.of(2027, 2, 25)))
|
||||
assertTrue(ScheduleEngine.occursOn(dt, LocalDate.of(2027, 3, 1)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extend override pushes this year's season end later then expires`() {
|
||||
val dt = doseTime(
|
||||
seasonStartMmdd = 301, seasonEndMmdd = 531,
|
||||
endEpochDay = LocalDate.of(2026, 6, 14).toEpochDay(),
|
||||
)
|
||||
assertTrue(ScheduleEngine.occursOn(dt, LocalDate.of(2026, 5, 31))) // nominal end
|
||||
assertTrue(ScheduleEngine.occursOn(dt, LocalDate.of(2026, 6, 1))) // bridge past nominal end
|
||||
assertTrue(ScheduleEngine.occursOn(dt, LocalDate.of(2026, 6, 14))) // override end
|
||||
assertFalse(ScheduleEngine.occursOn(dt, LocalDate.of(2026, 6, 15)))
|
||||
assertFalse(ScheduleEngine.occursOn(dt, LocalDate.of(2027, 6, 10))) // expired next year
|
||||
assertTrue(ScheduleEngine.occursOn(dt, LocalDate.of(2027, 4, 1))) // nominal repeats
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an early-started season counts as in-season for consumption and the resting check`() {
|
||||
val dt = doseTime(
|
||||
amount = 1.0, seasonStartMmdd = 301, seasonEndMmdd = 531,
|
||||
startEpochDay = LocalDate.of(2026, 2, 25).toEpochDay(),
|
||||
)
|
||||
assertEquals(1.0, ScheduleEngine.dailyConsumption(listOf(dt), LocalDate.of(2026, 2, 26)), 1e-9)
|
||||
assertTrue(ScheduleEngine.inSeasonNow(listOf(dt), LocalDate.of(2026, 2, 26)))
|
||||
assertFalse(ScheduleEngine.inSeasonNow(listOf(dt), LocalDate.of(2026, 1, 1))) // still resting
|
||||
}
|
||||
|
||||
// ---- multiple dose-times --------------------------------------------
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -32,6 +32,18 @@ class ScheduleTextTest {
|
|||
assertEquals("22:30 (daglig)", ScheduleText.describe(dt(minute = 22 * 60 + 30)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `seasonal window reads as a calendar range`() {
|
||||
val pollen = DoseTime(
|
||||
medId = 1, minuteOfDay = 480, amount = 1.0, anchorEpochDay = 0,
|
||||
seasonStartMmdd = 301, seasonEndMmdd = 531,
|
||||
)
|
||||
// nb abbreviates March to "mar." (trailing dot); May is already short.
|
||||
assertEquals("08:00 (daglig) · sesong 1. mar.–31. mai", ScheduleText.describe(pollen))
|
||||
// No season set → no suffix.
|
||||
assertEquals("", ScheduleText.seasonText(dt()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `amounts use norwegian decimal comma and drop trailing zeroes`() {
|
||||
assertEquals("1 tablett", ScheduleText.amountText(1.0, "tablett"))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue