core-reminders: multi-value reminder overrides

ReminderOverride.Minutes now carries a List<Int> of lead times so a target
can hold several reminders at once; an app that offers a single reminder uses a
one-element list. Map helpers move to Map<Long, List<Int>> (empty list = explicit
None), and the codec gains a third (list) separator while single legacy values
round-trip byte-identically. Adds reminderOverrideForMinutes/normalizeReminders/
reminderLeadsFor. Upstreamed from Calendula's per-calendar multi-reminders.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-06 21:11:24 +02:00
parent 566caf4305
commit bd874c2586
4 changed files with 125 additions and 57 deletions

View File

@@ -1,9 +1,12 @@
package de.jeanlucmakiola.floret.reminders
/**
* A target's override of the default reminder lead time — a list (Agendula) or a
* calendar (Calendula). Targets are keyed by id in the override map; this is the
* choice a picker returns.
* A target's override of the default reminder lead times — a list (Agendula) or
* a calendar (Calendula). A target can carry several lead times at once ("1 week
* before" *and* "on the day"), so the value is a set of minutes-before-due;
* an app that only offers a single reminder just uses a one-element list.
* Targets are keyed by id in the override map; this is the choice a picker
* returns.
*/
sealed interface ReminderOverride {
/** No override — the target uses the global default. */
@@ -12,33 +15,54 @@ sealed interface ReminderOverride {
/** Explicit "no reminder" for this target, regardless of the global default. */
data object None : ReminderOverride
/** A specific lead time in minutes before due. */
data class Minutes(val minutes: Int) : ReminderOverride
/** Specific lead times in minutes before due (non-empty, distinct, ascending). */
data class Minutes(val minutes: List<Int>) : ReminderOverride
}
/**
* The lead time for [id]: its override if one is set (a null map value means an
* explicit "no reminder"), otherwise the global [default]. A null result means
* no reminder. Mirrors the absent/null/value semantics of the override map.
*/
fun Map<Long, Int?>.reminderLeadFor(id: Long, default: Int): Int? =
if (containsKey(id)) this[id] else default
/** Distinct, ascending lead times — the canonical form stored and resolved. */
fun List<Int>.normalizeReminders(): List<Int> = distinct().sorted()
/**
* Read [id]'s entry as a [ReminderOverride]: absent → [Inherit], null → [None],
* a value → [Minutes].
* The lead times for [id]: its override if one is set (an empty-list value means
* an explicit "no reminder"), otherwise the global [default]. An empty result
* means no reminder. Mirrors the absent/empty/value semantics of the override
* map.
*/
fun Map<Long, Int?>.reminderOverrideFor(id: Long): ReminderOverride = when {
fun Map<Long, List<Int>>.reminderLeadsFor(id: Long, default: List<Int>): List<Int> =
if (containsKey(id)) getValue(id) else default
/**
* Read [id]'s entry as a [ReminderOverride]: absent → [Inherit], empty → [None],
* a non-empty value → [Minutes].
*/
fun Map<Long, List<Int>>.reminderOverrideFor(id: Long): ReminderOverride = when {
!containsKey(id) -> ReminderOverride.Inherit
this[id] == null -> ReminderOverride.None
else -> ReminderOverride.Minutes(getValue(id)!!)
getValue(id).isEmpty() -> ReminderOverride.None
else -> ReminderOverride.Minutes(getValue(id))
}
/** Apply [override] for [id] to this override map ([Inherit] removes the key). */
fun MutableMap<Long, Int?>.applyReminderOverride(id: Long, override: ReminderOverride) {
fun MutableMap<Long, List<Int>>.applyReminderOverride(id: Long, override: ReminderOverride) {
when (override) {
ReminderOverride.Inherit -> remove(id)
ReminderOverride.None -> put(id, null)
is ReminderOverride.Minutes -> put(id, override.minutes)
ReminderOverride.None -> put(id, emptyList())
is ReminderOverride.Minutes -> put(id, override.minutes.normalizeReminders())
}
}
/**
* The override a chosen lead-time set maps to when emitted from a picker. A
* non-empty set is [ReminderOverride.Minutes]; an empty set (the last time was
* cleared) reverts to [ReminderOverride.Inherit] on a per-target picker
* ([allowInherit]) — so an accidental clear can't silently wipe the target's
* default — and to explicit [ReminderOverride.None] otherwise (the global
* default, where empty legitimately means no reminder). Pure, for unit tests.
*/
fun reminderOverrideForMinutes(minutes: List<Int>, allowInherit: Boolean): ReminderOverride {
val norm = minutes.normalizeReminders()
return when {
norm.isNotEmpty() -> ReminderOverride.Minutes(norm)
allowInherit -> ReminderOverride.Inherit
else -> ReminderOverride.None
}
}

View File

@@ -1,35 +1,55 @@
package de.jeanlucmakiola.floret.reminders
/**
* Encodes a per-target reminder-override map (`id → minutes`, where a null value
* is an explicit "no reminder") to and from a single stored string, e.g.
* `"12:30;7:none"`.
* Encodes a per-target reminder-override map (`id → lead times`, where an empty
* list is an explicit "no reminder") to and from a single stored string, e.g.
* `"12:30,60;7:none"`.
*
* The separators are configurable because each app already has bytes on disk in
* its own dialect — Agendula stores `id:minutes`, Calendula `id=minutes` — and
* the stored format must stay stable. Use [DEFAULT] (Agendula's `:` / `;`) or
* build a codec with the app's own separators; never change an app's separators
* after release without a migration.
* Three separators, all configurable because each app already has bytes on disk
* in its own dialect and the stored format must stay stable:
* - [entrySep] between targets (`;`)
* - [keyValueSep] between a target id and its lead times (Agendula `:`,
* Calendula `=`)
* - [listSep] between lead times within one target (`,`)
*
* A single legacy value (`id:30`, no [listSep]) still parses to a one-element
* list, and a one-element list serialises back with no [listSep] — so an app
* that stored a single value per target before multi-reminders round-trips
* byte-identically. Only the exact [noneToken] means an explicit no-reminder
* override (empty list); a non-sentinel value that parses to no valid minutes is
* garbage and drops the entry, so the target inherits the global default rather
* than silently reading as "no reminder". Never change an app's separators after
* release without a migration.
*/
class ReminderOverrideCodec(
private val entrySep: String = ";",
private val keyValueSep: String = ":",
private val listSep: String = ",",
private val noneToken: String = "none",
) {
/** Decode the stored string into a map (a null value = explicit no-reminder). */
fun parse(stored: String?): Map<Long, Int?> {
/** Decode the stored string into a map (an empty-list value = explicit no-reminder). */
fun parse(stored: String?): Map<Long, List<Int>> {
if (stored.isNullOrBlank()) return emptyMap()
return stored.split(entrySep).mapNotNull { entry ->
val parts = entry.split(keyValueSep).takeIf { it.size == 2 } ?: return@mapNotNull null
val id = parts[0].toLongOrNull() ?: return@mapNotNull null
val value = if (parts[1] == noneToken) null else parts[1].toIntOrNull() ?: return@mapNotNull null
id to value
when (val value = parts[1]) {
noneToken -> id to emptyList()
else -> value.split(listSep).mapNotNull { it.trim().toIntOrNull() }
.normalizeReminders().takeIf { it.isNotEmpty() }?.let { id to it }
?: return@mapNotNull null
}
}.toMap()
}
/** Encode the map back to its stored string form. */
fun serialize(map: Map<Long, Int?>): String =
map.entries.joinToString(entrySep) { (id, minutes) -> "$id$keyValueSep${minutes ?: noneToken}" }
fun serialize(map: Map<Long, List<Int>>): String =
map.entries.joinToString(entrySep) { (id, minutes) ->
val value =
if (minutes.isEmpty()) noneToken
else minutes.normalizeReminders().joinToString(listSep) { it.toString() }
"$id$keyValueSep$value"
}
companion object {
/** Agendula's dialect: `id:minutes` entries joined by `;`. */

View File

@@ -21,32 +21,47 @@ class ReminderModelTest {
}
@Test
fun `reminderLeadFor distinguishes absent, explicit-none and a value`() {
val map = mapOf(1L to 30, 2L to null)
assertThat(map.reminderLeadFor(1L, default = 10)).isEqualTo(30)
assertThat(map.reminderLeadFor(2L, default = 10)).isNull() // explicit no-reminder
assertThat(map.reminderLeadFor(3L, default = 10)).isEqualTo(10) // inherits default
fun `normalize dedupes and sorts`() {
assertThat(listOf(60, 10, 60, 0).normalizeReminders()).containsExactly(0, 10, 60).inOrder()
}
@Test
fun `reminderOverrideFor maps absent, null and value to the choice types`() {
val map = mapOf(1L to 30, 2L to null)
assertThat(map.reminderOverrideFor(1L)).isEqualTo(ReminderOverride.Minutes(30))
fun `reminderLeadsFor distinguishes absent, explicit-none and a value`() {
val map = mapOf(1L to listOf(30, 60), 2L to emptyList())
assertThat(map.reminderLeadsFor(1L, default = listOf(10))).containsExactly(30, 60).inOrder()
assertThat(map.reminderLeadsFor(2L, default = listOf(10))).isEmpty() // explicit no-reminder
assertThat(map.reminderLeadsFor(3L, default = listOf(10))).containsExactly(10) // inherits default
}
@Test
fun `reminderOverrideFor maps absent, empty and value to the choice types`() {
val map = mapOf(1L to listOf(30, 60), 2L to emptyList())
assertThat(map.reminderOverrideFor(1L)).isEqualTo(ReminderOverride.Minutes(listOf(30, 60)))
assertThat(map.reminderOverrideFor(2L)).isEqualTo(ReminderOverride.None)
assertThat(map.reminderOverrideFor(3L)).isEqualTo(ReminderOverride.Inherit)
}
@Test
fun `applyReminderOverride writes, clears and removes entries`() {
val map = mutableMapOf<Long, Int?>()
map.applyReminderOverride(1L, ReminderOverride.Minutes(45))
assertThat(map).containsExactly(1L, 45)
fun `applyReminderOverride writes normalized, clears and removes entries`() {
val map = mutableMapOf<Long, List<Int>>()
map.applyReminderOverride(1L, ReminderOverride.Minutes(listOf(60, 45, 45)))
assertThat(map.getValue(1L)).containsExactly(45, 60).inOrder() // deduped + sorted
map.applyReminderOverride(1L, ReminderOverride.None)
assertThat(map[1L]).isNull()
assertThat(map.getValue(1L)).isEmpty()
assertThat(map).containsKey(1L)
map.applyReminderOverride(1L, ReminderOverride.Inherit)
assertThat(map).doesNotContainKey(1L)
}
@Test
fun `reminderOverrideForMinutes maps an empty set to inherit or none by allowInherit`() {
assertThat(reminderOverrideForMinutes(listOf(60, 10, 10), allowInherit = true))
.isEqualTo(ReminderOverride.Minutes(listOf(10, 60)))
assertThat(reminderOverrideForMinutes(emptyList(), allowInherit = true))
.isEqualTo(ReminderOverride.Inherit)
assertThat(reminderOverrideForMinutes(emptyList(), allowInherit = false))
.isEqualTo(ReminderOverride.None)
}
}

View File

@@ -8,8 +8,8 @@ class ReminderOverrideCodecTest {
private val codec = ReminderOverrideCodec.DEFAULT
@Test
fun `round-trips ids, minutes and explicit none`() {
val map = mapOf(12L to 30, 7L to null, 3L to 0)
fun `round-trips ids, multi-value minutes and explicit none`() {
val map = mapOf(12L to listOf(30, 60), 7L to emptyList(), 3L to listOf(0))
assertThat(codec.parse(codec.serialize(map))).isEqualTo(map)
}
@@ -26,24 +26,33 @@ class ReminderOverrideCodecTest {
}
@Test
fun `none token decodes to a null value`() {
assertThat(codec.parse("7:none")).isEqualTo(mapOf(7L to null))
fun `none token decodes to an empty-list value`() {
assertThat(codec.parse("7:none")).isEqualTo(mapOf(7L to emptyList<Int>()))
}
@Test
fun `garbage entries are dropped, valid ones kept`() {
assertThat(codec.parse("12:30;garbage;x:5;9:notnum;4:10"))
.isEqualTo(mapOf(12L to 30, 4L to 10))
fun `a legacy single value round-trips byte-identically`() {
// Pre-multi-reminders each target stored one value ("30"); it must still
// parse to a one-element list and serialise back with no list separator.
assertThat(codec.parse("12:30")).isEqualTo(mapOf(12L to listOf(30)))
assertThat(codec.serialize(mapOf(12L to listOf(30)))).isEqualTo("12:30")
}
@Test
fun `garbage entries are dropped, none is kept, valid ones kept`() {
// 9:notnum parses to no valid minutes → dropped (inherit), not read as none.
assertThat(codec.parse("12:30,60;garbage;x:5;9:notnum;7:none;4:10"))
.isEqualTo(mapOf(12L to listOf(30, 60), 7L to emptyList(), 4L to listOf(10)))
}
@Test
fun `a custom dialect keeps a separate on-disk format`() {
// Calendula's dialect: id=minutes entries.
val calendula = ReminderOverrideCodec(entrySep = ";", keyValueSep = "=", noneToken = "none")
val map = mapOf(12L to 30, 7L to null)
val calendula = ReminderOverrideCodec(entrySep = ";", keyValueSep = "=", listSep = ",", noneToken = "none")
val map = mapOf(12L to listOf(30, 60), 7L to emptyList())
val stored = calendula.serialize(map)
assertThat(stored).contains("12=30")
assertThat(stored).contains("12=30,60")
assertThat(stored).contains("7=none")
assertThat(calendula.parse(stored)).isEqualTo(map)
// The Agendula dialect cannot read Calendula's bytes — the formats are distinct.