Reminders: one grouped card for co-timed meds, acked together

Meds due at the same wall-clock minute now share a single notification
instead of one card each — they resolve to an identical scheduledAtMillis,
which becomes the occurrence (and notification) key. Per the feature
request: "Tatt alle" / "Utsett alle" handle the whole morning batch in
one tap; a single med just renders as a normal card with "Tatt".

- Notification id is per-occurrence (scheduledAtMillis), not per-log, so
  co-timed doses collapse onto one card and re-nags replace it.
- DoseActionReceiver buttons carry the occurrence and call group ops
  (takeAll/snoozeAll/muteAll); DoseActions keeps a single per-dose state
  mutation (takeState/snoozeState/muteState) reused by both the group ops
  and the app's per-dose actions, so the two surfaces can't diverge.
- The card is rebuilt from getActiveAtOccurrence: still-unresolved doses
  with nagCount <= cap. "Ikke forstyrr" now sets nagCount above the cap so
  a muted dose drops off the card, while a naturally-capped dose (== cap)
  stays visible and actionable.
- refreshOccurrence re-renders silently after a single in-app action and
  never creates a card for a not-yet-due occurrence.

Adds DoseActionsGroupTest (instrumented) covering takeAll, muteAll, and
the muted-vs-capped distinction at the data layer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-06-29 14:28:18 +02:00
commit 44ea4ed87b
6 changed files with 309 additions and 82 deletions

View file

@ -0,0 +1,104 @@
package no.naiv.meddetsamme.domain
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.runBlocking
import no.naiv.meddetsamme.alarm.AlarmScheduler
import no.naiv.meddetsamme.data.DoseLog
import no.naiv.meddetsamme.data.DoseStatus
import no.naiv.meddetsamme.data.DoseTime
import no.naiv.meddetsamme.data.InventoryItem
import no.naiv.meddetsamme.data.MedDatabase
import no.naiv.meddetsamme.data.MedForm
import no.naiv.meddetsamme.data.Medication
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
/**
* The grouped-notification contract at the data layer: co-timed doses (an
* identical scheduledAtMillis) form one occurrence, "Tatt alle" resolves them
* together, and "Ikke forstyrr alle" drops them off the card without marking
* them skipped. Drives DoseActions directly the single Taken/Snooze/Skip path
* since the notification surface itself can't be driven deterministically.
*/
@RunWith(AndroidJUnit4::class)
class DoseActionsGroupTest {
private val context = ApplicationProvider.getApplicationContext<android.content.Context>()
private val db get() = MedDatabase.get(context)
private val actions = DoseActions(context)
/** A fixed PAST instant both doses share — that shared value *is* the group. */
private val occurrence = 1_600_000_000_000L
@Before fun clear() = runBlocking { db.restoreAll(emptyList(), emptyList(), emptyList(), emptyList()) }
@After fun cleanup() = runBlocking { db.restoreAll(emptyList(), emptyList(), emptyList(), emptyList()) }
/** One med + one dose log due at [occurrence]; returns (logId, itemId). */
private suspend fun seedDose(name: String, stock: Double, amount: Double): Pair<Long, Long> {
val itemId = db.inventoryDao().insert(
InventoryItem(name = name, strength = "", unit = "tablett", form = MedForm.TABLET, stockUnits = stock),
)
val medId = db.medicationDao().insert(Medication(itemId = itemId))
val doseTimeId = db.doseTimeDao().insert(DoseTime(medId = medId, minuteOfDay = 480, amount = amount))
val logId = db.doseLogDao().insert(
DoseLog(medId = medId, doseTimeId = doseTimeId, scheduledAtMillis = occurrence, amount = amount),
)
return logId to itemId
}
private suspend fun active() =
db.doseLogDao().getActiveAtOccurrence(occurrence, AlarmScheduler.NAG_CAP)
@Test fun takeAllResolvesEveryCoTimedDoseAndDecrementsEach() = runBlocking {
val (logA, _) = seedDose("A", stock = 10.0, amount = 1.0)
val (logB, _) = seedDose("B", stock = 20.0, amount = 2.0)
assertEquals("both due at the same instant are one occurrence", 2, active().size)
actions.takeAll(occurrence)
assertEquals(DoseStatus.TAKEN, db.doseLogDao().getById(logA)!!.status)
assertEquals(DoseStatus.TAKEN, db.doseLogDao().getById(logB)!!.status)
assertTrue("nothing left to nag", active().isEmpty())
val stock = db.inventoryDao().getAll().associate { it.name to it.stockUnits }
assertEquals(9.0, stock["A"]!!, 1e-9)
assertEquals(18.0, stock["B"]!!, 1e-9)
}
@Test fun muteAllStopsNaggingButKeepsDosesPending() = runBlocking {
seedDose("A", 10.0, 1.0)
seedDose("B", 20.0, 1.0)
actions.muteAll(occurrence)
val logs = db.doseLogDao().getRange(occurrence, occurrence + 1)
assertTrue("muted doses stay unresolved, not skipped", logs.all { it.status == DoseStatus.PENDING })
assertTrue("nagCount above the cap = won't re-nag, won't resume on reboot", logs.all { it.nagCount > AlarmScheduler.NAG_CAP })
assertTrue("muted doses drop off the card", active().isEmpty())
}
@Test fun mutedDoseLeavesCoTimedSiblingActive() = runBlocking {
val (logA, _) = seedDose("A", 10.0, 1.0)
val (logB, _) = seedDose("B", 10.0, 1.0)
// Mute only A (its nagCount above the cap); B is untouched.
db.doseLogDao().setNagCount(logA, AlarmScheduler.NAG_CAP + 1)
val remaining = active()
assertEquals(1, remaining.size)
assertEquals("B still nags; A is silenced", logB, remaining.single().id)
}
@Test fun naturallyCappedDoseStaysActionableOnCard() = runBlocking {
val (logA, _) = seedDose("A", 10.0, 1.0)
// Reached the nag cap on its own — still shown (== cap), unlike a mute.
db.doseLogDao().setNagCount(logA, AlarmScheduler.NAG_CAP)
assertEquals(1, active().size)
}
}

View file

@ -9,45 +9,41 @@ import kotlinx.coroutines.launch
import no.naiv.meddetsamme.domain.DoseActions
/**
* Handles the notification actions (and later, the same actions from the UI).
* Handles the notification actions. The dose card groups every med due at the
* same wall-clock minute, so its buttons act on the whole occurrence (carried
* as EXTRA_SCHEDULED_AT), not a single log "Tatt alle" / "Utsett alle".
*
* Contract (CLAUDE.md, load-bearing):
* Contract (CLAUDE.md, load-bearing), applied to each dose in the occurrence:
* - Taken TAKEN, decrement inventory by the *logged* amount, stop escalating.
* - Snooze SNOOZED, next nag in 15 min, keeps escalating (and counting) after.
* - Skip SKIPPED, stop escalating, no inventory change.
* - Mute "Ikke forstyrr": stop escalating, dose stays unresolved.
* (Skip stays a per-dose UI action; the notification never offers it.)
*/
class DoseActionReceiver : BroadcastReceiver() {
companion object {
const val ACTION_TAKEN = "no.naiv.meddetsamme.action.TAKEN"
const val ACTION_SNOOZE = "no.naiv.meddetsamme.action.SNOOZE"
const val ACTION_SKIP = "no.naiv.meddetsamme.action.SKIP"
const val ACTION_MUTE = "no.naiv.meddetsamme.action.MUTE"
const val EXTRA_LOG_ID = "logId"
const val EXTRA_SCHEDULED_AT = "scheduledAt"
}
override fun onReceive(context: Context, intent: Intent) {
val logId = intent.getLongExtra(EXTRA_LOG_ID, -1)
if (logId < 0) return
val scheduledAt = intent.getLongExtra(EXTRA_SCHEDULED_AT, -1)
if (scheduledAt < 0) return
val action = intent.action ?: return
val pending = goAsync()
CoroutineScope(Dispatchers.IO).launch {
try {
handle(context, action, logId)
val actions = DoseActions(context)
when (action) {
ACTION_TAKEN -> actions.takeAll(scheduledAt)
ACTION_SNOOZE -> actions.snoozeAll(scheduledAt)
ACTION_MUTE -> actions.muteAll(scheduledAt)
}
} finally {
pending.finish()
}
}
}
private suspend fun handle(context: Context, action: String, logId: Long) {
val actions = DoseActions(context)
when (action) {
ACTION_TAKEN -> actions.take(logId)
ACTION_SNOOZE -> actions.snooze(logId)
ACTION_SKIP -> actions.skip(logId)
ACTION_MUTE -> actions.mute(logId)
}
}
}

View file

@ -80,11 +80,7 @@ class DoseAlarmReceiver : BroadcastReceiver() {
else -> existing
}
Notifications.post(
context,
Notifications.doseNotificationId(log.id),
Notifications.buildDoseNotification(context, med, log),
)
postOccurrence(context, db, scheduledAt)
// Start the automatic re-nag chain only if the user wants repeats; an
// explicit Snooze still re-arms regardless (DoseActions.snooze).
if (repeatReminders(context) && log.nagCount < AlarmScheduler.NAG_CAP) {
@ -102,15 +98,11 @@ class DoseAlarmReceiver : BroadcastReceiver() {
// depth against any path that arms one too early).
if (log.scheduledAtMillis > System.currentTimeMillis()) return
val med = db.medicationDao().getById(log.medId) ?: return
val nagged = log.copy(nagCount = log.nagCount + 1)
db.doseLogDao().setNagCount(logId, nagged.nagCount)
Notifications.post(
context,
Notifications.doseNotificationId(logId),
Notifications.buildDoseNotification(context, med, nagged),
)
// Re-post the whole occurrence so co-timed siblings stay on one card.
postOccurrence(context, db, log.scheduledAtMillis)
// Re-arm the next automatic nag only while repeats are enabled. A nag
// already in flight from a Snooze still fires once (this post), then
// stops here — Snooze stays a single deferral, not an endless chain.
@ -119,6 +111,23 @@ class DoseAlarmReceiver : BroadcastReceiver() {
}
}
/**
* Post (or replace) the single grouped card for one wall-clock occurrence:
* every med due at that minute, still active (not taken/skipped/muted). If
* nothing's active it clears the card a redundant escalation that raced a
* "Tatt alle" then just tidies up.
*/
private suspend fun postOccurrence(context: Context, db: MedDatabase, scheduledAtMillis: Long) {
val active = db.doseLogDao().getActiveAtOccurrence(scheduledAtMillis, AlarmScheduler.NAG_CAP)
val id = Notifications.doseNotificationId(scheduledAtMillis)
val entries = active.mapNotNull { l -> db.medicationDao().getById(l.medId)?.let { it to l } }
if (entries.isEmpty()) {
Notifications.cancel(context, id)
return
}
Notifications.post(context, id, Notifications.buildDoseNotification(context, entries, scheduledAtMillis))
}
private fun repeatReminders(context: Context): Boolean =
no.naiv.meddetsamme.settings.SettingsStore(context).repeatReminders
}

View file

@ -22,6 +22,22 @@ interface DoseLogDao {
@Query("SELECT * FROM dose_log WHERE status IN ('PENDING', 'SNOOZED') ORDER BY scheduledAtMillis")
suspend fun getUnresolved(): List<DoseLog>
/**
* Co-timed doses to show on one card: same wall-clock occurrence, still
* unresolved, nagCount <= [maxNag]. Pass NAG_CAP so a naturally-exhausted
* dose (== cap) still shows and stays actionable, while "Ikke forstyrr"
* (which sets nagCount above the cap) drops out otherwise a muted dose
* would reappear via a co-timed sibling's re-nag.
*/
@Query(
"""SELECT * FROM dose_log
WHERE scheduledAtMillis = :scheduledAtMillis
AND status IN ('PENDING', 'SNOOZED')
AND nagCount <= :maxNag
ORDER BY id""",
)
suspend fun getActiveAtOccurrence(scheduledAtMillis: Long, maxNag: Int): List<DoseLog>
@Query("SELECT * FROM dose_log WHERE scheduledAtMillis >= :fromMillis AND scheduledAtMillis < :toMillis ORDER BY scheduledAtMillis")
fun observeRange(fromMillis: Long, toMillis: Long): Flow<List<DoseLog>>

View file

@ -35,42 +35,44 @@ class DoseActions(private val context: Context) {
return db.doseLogDao().getById(id)!!
}
// ---- single-dose actions (the app's Today screen acts per dose) --------
suspend fun take(logId: Long) {
val log = db.doseLogDao().getById(logId) ?: return
if (log.status == DoseStatus.TAKEN) return // double-tap must not decrement twice
db.doseLogDao().setStatus(logId, DoseStatus.TAKEN, System.currentTimeMillis())
db.medicationDao().getRawById(log.medId)?.let {
db.inventoryDao().decrementStock(it.itemId, log.amount)
}
scheduler.cancelEscalation(logId)
Notifications.cancel(context, Notifications.doseNotificationId(logId))
takeState(log)
refreshOccurrence(log.scheduledAtMillis)
}
suspend fun snooze(logId: Long) {
db.doseLogDao().setStatus(logId, DoseStatus.SNOOZED, System.currentTimeMillis())
scheduler.armEscalation(logId, delayMinutes = AlarmScheduler.SNOOZE_MINUTES)
Notifications.cancel(context, Notifications.doseNotificationId(logId))
val log = db.doseLogDao().getById(logId) ?: return
snoozeState(log)
// Snooze dismisses now; the re-nag brings the card back in 15 min.
Notifications.cancel(context, Notifications.doseNotificationId(log.scheduledAtMillis))
}
suspend fun skip(logId: Long) {
val log = db.doseLogDao().getById(logId) ?: return
db.doseLogDao().setStatus(logId, DoseStatus.SKIPPED, System.currentTimeMillis())
scheduler.cancelEscalation(logId)
Notifications.cancel(context, Notifications.doseNotificationId(logId))
refreshOccurrence(log.scheduledAtMillis)
}
/**
* "Ikke forstyrr": stop nagging this dose but leave it unresolved the user
* takes responsibility to remember it. Reuses the existing terminal state
* (nagCount at the cap) rather than a new flag, so escalation stops *and*
* armAll() won't resume it after a reboot (it re-arms only nagCount < cap).
* The dose stays PENDING, so it can still be taken from the app later.
*/
suspend fun mute(logId: Long) {
val log = db.doseLogDao().getById(logId) ?: return
if (log.status != DoseStatus.PENDING && log.status != DoseStatus.SNOOZED) return
db.doseLogDao().setNagCount(logId, AlarmScheduler.NAG_CAP)
scheduler.cancelEscalation(logId)
Notifications.cancel(context, Notifications.doseNotificationId(logId))
// ---- occurrence (group) actions — the notification buttons -------------
// Every med due at the same wall-clock minute, handled in one tap.
suspend fun takeAll(scheduledAtMillis: Long) {
activeAt(scheduledAtMillis).forEach { takeState(it) }
refreshOccurrence(scheduledAtMillis)
}
suspend fun snoozeAll(scheduledAtMillis: Long) {
activeAt(scheduledAtMillis).forEach { snoozeState(it) }
Notifications.cancel(context, Notifications.doseNotificationId(scheduledAtMillis))
}
suspend fun muteAll(scheduledAtMillis: Long) {
activeAt(scheduledAtMillis).forEach { muteState(it) }
refreshOccurrence(scheduledAtMillis)
}
/** Undo a mis-tap: back to PENDING, restoring stock if it was TAKEN. */
@ -87,4 +89,62 @@ class DoseActions(private val context: Context) {
db.doseLogDao().setNagCount(logId, 0)
scheduler.cancelEscalation(logId)
}
// ---- shared state mutations (single source of truth per dose) ----------
private suspend fun takeState(log: DoseLog) {
if (log.status == DoseStatus.TAKEN) return // double-tap must not decrement twice
db.doseLogDao().setStatus(log.id, DoseStatus.TAKEN, System.currentTimeMillis())
db.medicationDao().getRawById(log.medId)?.let {
db.inventoryDao().decrementStock(it.itemId, log.amount)
}
scheduler.cancelEscalation(log.id)
}
private suspend fun snoozeState(log: DoseLog) {
db.doseLogDao().setStatus(log.id, DoseStatus.SNOOZED, System.currentTimeMillis())
scheduler.armEscalation(log.id, delayMinutes = AlarmScheduler.SNOOZE_MINUTES)
}
/**
* "Ikke forstyrr": stop nagging this dose but leave it unresolved the user
* takes over. Setting nagCount *above* the cap stops escalation, keeps
* armAll() from resuming it after a reboot (it re-arms only nagCount < cap),
* and drops it from the grouped card while a naturally-capped dose
* (== cap) stays visible and actionable. Status stays PENDING, so the dose
* can still be taken from the app later.
*/
private suspend fun muteState(log: DoseLog) {
if (log.status != DoseStatus.PENDING && log.status != DoseStatus.SNOOZED) return
db.doseLogDao().setNagCount(log.id, AlarmScheduler.NAG_CAP + 1)
scheduler.cancelEscalation(log.id)
}
private suspend fun activeAt(scheduledAtMillis: Long): List<DoseLog> =
db.doseLogDao().getActiveAtOccurrence(scheduledAtMillis, AlarmScheduler.NAG_CAP)
/**
* Re-render the single occurrence card to match DB state, or clear it when
* nothing's left to act on. Silent (no re-alert) this fires after the user
* already handled a dose, so buzzing for the untouched ones would be wrong.
* Never *creates* a card for a not-yet-due occurrence (taking tomorrow's
* dose early from the app must not post a reminder for today).
*/
private suspend fun refreshOccurrence(scheduledAtMillis: Long) {
val id = Notifications.doseNotificationId(scheduledAtMillis)
val active = activeAt(scheduledAtMillis)
if (active.isEmpty()) {
Notifications.cancel(context, id)
return
}
if (scheduledAtMillis > System.currentTimeMillis()) return // future: no card to refresh
val entries = active.mapNotNull { log ->
db.medicationDao().getById(log.medId)?.let { it to log }
}
if (entries.isEmpty()) {
Notifications.cancel(context, id)
return
}
Notifications.post(context, id, Notifications.buildDoseNotification(context, entries, scheduledAtMillis, alertAgain = false))
}
}

View file

@ -23,8 +23,14 @@ object Notifications {
const val CHANNEL_DOSES = "doses"
const val CHANNEL_SUPPLY = "supply"
/** Stable per-occurrence notification id: every re-nag *replaces* the card. */
fun doseNotificationId(logId: Long): Int = (logId % Int.MAX_VALUE).toInt()
/**
* Stable per-OCCURRENCE notification id (minute resolution): every med due
* at the same wall-clock minute shares one card, and every re-nag replaces
* it. Keyed on scheduledAtMillis because co-timed doses resolve to an
* identical value that's the whole grouping mechanism.
*/
fun doseNotificationId(scheduledAtMillis: Long): Int =
((scheduledAtMillis / 60_000) % Int.MAX_VALUE).toInt()
const val SUPPLY_NOTIFICATION_ID = 1_000_000_007
const val RX_NOTIFICATION_ID = 1_000_000_009
@ -59,37 +65,67 @@ object Notifications {
}
/**
* The dose reminder card. nagCount > 0 marks a re-nag same id, so it
* replaces the previous card but re-alerts (no setOnlyAlertOnce: re-alerting
* is the escalation).
* The dose reminder card for one occurrence. [entries] are all the meds due
* at the same wall-clock minute (one entry = a normal single card; several =
* a grouped card with "alle" actions). Actions carry the scheduledAtMillis,
* not a log id, so they act on the whole occurrence.
*
* [alertAgain] = false (setOnlyAlertOnce) is for silent in-place refreshes
* after a single dose is handled in the app re-alerting there would buzz
* for the doses you *didn't* touch. Fire and escalation leave it true:
* re-alerting is the escalation.
*/
fun buildDoseNotification(context: Context, med: MedWithItem, log: DoseLog): Notification {
val scheduled = Instant.ofEpochMilli(log.scheduledAtMillis).atZone(ZoneId.systemDefault())
val title = med.displayName
val body = buildString {
append("${formatAmount(log.amount)} ${med.item.unit} kl. ${timeFmt.format(scheduled)}")
if (med.med.withFood) append(" — tas med mat")
if (log.nagCount > 0) append(" (påminnelse ${log.nagCount})")
fun buildDoseNotification(
context: Context,
entries: List<Pair<MedWithItem, DoseLog>>,
scheduledAtMillis: Long,
alertAgain: Boolean = true,
): Notification {
require(entries.isNotEmpty()) { "Dose notification needs at least one med" }
val time = timeFmt.format(Instant.ofEpochMilli(scheduledAtMillis).atZone(ZoneId.systemDefault()))
val grouped = entries.size > 1
val maxNag = entries.maxOf { it.second.nagCount }
val nagSuffix = if (maxNag > 0) " (påminnelse $maxNag)" else ""
fun lineFor(med: MedWithItem, log: DoseLog) = buildString {
append("${med.displayName}: ${formatAmount(log.amount)} ${med.item.unit}")
if (med.med.withFood) append(" — med mat")
}
fun action(action: String): PendingIntent = PendingIntent.getBroadcast(
val title: String
val body: String
if (grouped) {
title = "${entries.size} medisiner kl. $time"
body = entries.joinToString(" · ") { (m, l) -> "${m.displayName} ${formatAmount(l.amount)} ${m.item.unit}" } + nagSuffix
} else {
val (m, l) = entries.first()
title = m.displayName
body = buildString {
append("${formatAmount(l.amount)} ${m.item.unit} kl. $time")
if (m.med.withFood) append(" — tas med mat")
append(nagSuffix)
}
}
fun action(actionStr: String): PendingIntent = PendingIntent.getBroadcast(
context,
// Distinct requestCode per (log, action); same (log, action) replaces.
(log.id * 10 + action.hashCode().mod(10)).toInt(),
// One requestCode per occurrence; the Intent action keeps the
// Tatt/Utsett/Ikke-forstyrr PendingIntents distinct (filterEquals).
doseNotificationId(scheduledAtMillis),
Intent(context, DoseActionReceiver::class.java)
.setAction(action)
.putExtra(DoseActionReceiver.EXTRA_LOG_ID, log.id),
.setAction(actionStr)
.putExtra(DoseActionReceiver.EXTRA_SCHEDULED_AT, scheduledAtMillis),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val contentIntent = PendingIntent.getActivity(
context,
doseNotificationId(log.id),
doseNotificationId(scheduledAtMillis),
Intent(context, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
return NotificationCompat.Builder(context, CHANNEL_DOSES)
val builder = NotificationCompat.Builder(context, CHANNEL_DOSES)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(body)
@ -98,16 +134,22 @@ object Notifications {
.setContentIntent(contentIntent)
.setAutoCancel(false)
.setOngoing(false)
.addAction(0, "Tatt", action(DoseActionReceiver.ACTION_TAKEN))
.addAction(0, "Utsett 15 min", action(DoseActionReceiver.ACTION_SNOOZE))
// From the second reminder on, offer an escape hatch: stop nagging
// this dose without marking it skipped (the user takes over).
.apply {
if (log.nagCount >= 1) {
addAction(0, "Ikke forstyrr", action(DoseActionReceiver.ACTION_MUTE))
}
}
.build()
.setOnlyAlertOnce(!alertAgain)
.addAction(0, if (grouped) "Tatt alle" else "Tatt", action(DoseActionReceiver.ACTION_TAKEN))
.addAction(0, if (grouped) "Utsett alle" else "Utsett 15 min", action(DoseActionReceiver.ACTION_SNOOZE))
if (grouped) {
builder.setStyle(
NotificationCompat.InboxStyle()
.setBigContentTitle("Medisiner kl. $time$nagSuffix")
.also { s -> entries.forEach { (m, l) -> s.addLine(lineFor(m, l)) } },
)
}
// From the second reminder on, offer an escape hatch: stop nagging this
// occurrence without marking it skipped (the user takes over).
if (maxNag >= 1) {
builder.addAction(0, if (grouped) "Ikke forstyrr alle" else "Ikke forstyrr", action(DoseActionReceiver.ACTION_MUTE))
}
return builder.build()
}
/** Morning low-stock digest: plainly dismissable, re-posted daily while low. */