Compare commits

...

2 commits

Author SHA1 Message Date
5b9a3d6877 Release 0.5.0: bump versionCode 5 / versionName 0.5.0
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 11:09:46 +02:00
d81a4fd926 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>
2026-06-24 11:09:46 +02:00
16 changed files with 1100 additions and 105 deletions

View file

@ -33,8 +33,8 @@ android {
targetSdk = 35
// Bump both for every release installed on the phone — versionName for
// humans, versionCode so dumpsys/install history can tell builds apart.
versionCode = 4
versionName = "0.4.0"
versionCode = 5
versionName = "0.5.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

View 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')"
]
}
}

View file

@ -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"
}

View file

@ -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

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

View file

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

View file

@ -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 NovFeb).
*/
@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,
)

View file

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

View file

@ -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 11010228 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 {

View file

@ -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. mar31. 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)"

View file

@ -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,

View file

@ -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 MarMay.
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 marsmai. 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),
)
}
}

View file

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

View file

@ -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 FebMar 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 MarMay; "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

View file

@ -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"))