med-det-samme/app/src/main/java/no/naiv/meddetsamme/domain/ScheduleText.kt
Ole-Morten Duesund 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

90 lines
3.6 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package no.naiv.meddetsamme.domain
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.util.Locale
import no.naiv.meddetsamme.data.DoseTime
/**
* Human-readable schedule descriptions (Norwegian, 24h) for the doctor
* summary and the UI. Pure — unit-tested like the engine, since a wrong
* description on a doctor's desk is a clinical communication error.
*/
object ScheduleText {
/** Mask bit order matches ScheduleEngine: bit 0 = Sunday. */
private val DAY_ABBREV = listOf("søn", "man", "tir", "ons", "tor", "fre", "lør")
// Locale-pinned to nb so month names don't come out English on a foreign locale.
private val dateFmt = DateTimeFormatter.ofPattern("d. MMM", Locale.forLanguageTag("nb"))
fun describe(doseTime: DoseTime): String =
"${"%02d:%02d".format(doseTime.minuteOfDay / 60, doseTime.minuteOfDay % 60)} " +
"${daysText(doseTime)}${cycleText(doseTime)}${windowText(doseTime)}${seasonText(doseTime)}"
/** " · syklus 21/28" when cyclic, else "". */
fun cycleText(doseTime: DoseTime): String {
val length = doseTime.cycleLengthDays
val active = doseTime.cycleActiveDays
return if (length > 0 && active in 1 until length) " · syklus $active/$length" else ""
}
/** " · 1.14. jun", " · f.o.m. 1. jun", " · t.o.m. 14. jun" for a validity window. */
fun windowText(doseTime: DoseTime): String {
val start = doseTime.startEpochDay?.let { LocalDate.ofEpochDay(it) }
val end = doseTime.endEpochDay?.let { LocalDate.ofEpochDay(it) }
return when {
start != null && end != null -> " · ${dateFmt.format(start)}${dateFmt.format(end)}"
start != null -> " · f.o.m. ${dateFmt.format(start)}"
end != null -> " · t.o.m. ${dateFmt.format(end)}"
else -> ""
}
}
/** " · 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)"
}
return when (val mask = doseTime.daysOfWeekMask and 0x7F) {
0x7F -> "(daglig)"
0x3E -> "(manfre)"
0x41 -> "(lørsøn)"
0 -> "(aldri)"
else -> {
// Week listed Monday-first for humans, despite bit 0 = Sunday.
val days = (1..7).map { it % 7 }
.filter { mask and (1 shl it) != 0 }
.map { DAY_ABBREV[it] }
"(${days.joinToString(", ")})"
}
}
}
/** "1 dag", "3 dager" — norsk flertall. */
fun daysCount(days: Int): String = if (days == 1) "1 dag" else "$days dager"
/** "1 tablett", "2,5 ml" — norsk desimalkomma. */
fun amountText(amount: Double, unit: String): String {
val number = if (amount == amount.toLong().toDouble()) {
amount.toLong().toString()
} else {
amount.toString().replace('.', ',')
}
return "$number $unit"
}
}