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:
@@ -1,9 +1,12 @@
|
|||||||
package de.jeanlucmakiola.floret.reminders
|
package de.jeanlucmakiola.floret.reminders
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A target's override of the default reminder lead time — a list (Agendula) or a
|
* A target's override of the default reminder lead times — a list (Agendula) or
|
||||||
* calendar (Calendula). Targets are keyed by id in the override map; this is the
|
* a calendar (Calendula). A target can carry several lead times at once ("1 week
|
||||||
* choice a picker returns.
|
* 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 {
|
sealed interface ReminderOverride {
|
||||||
/** No override — the target uses the global default. */
|
/** 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. */
|
/** Explicit "no reminder" for this target, regardless of the global default. */
|
||||||
data object None : ReminderOverride
|
data object None : ReminderOverride
|
||||||
|
|
||||||
/** A specific lead time in minutes before due. */
|
/** Specific lead times in minutes before due (non-empty, distinct, ascending). */
|
||||||
data class Minutes(val minutes: Int) : ReminderOverride
|
data class Minutes(val minutes: List<Int>) : ReminderOverride
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Distinct, ascending lead times — the canonical form stored and resolved. */
|
||||||
* The lead time for [id]: its override if one is set (a null map value means an
|
fun List<Int>.normalizeReminders(): List<Int> = distinct().sorted()
|
||||||
* 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
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read [id]'s entry as a [ReminderOverride]: absent → [Inherit], null → [None],
|
* The lead times for [id]: its override if one is set (an empty-list value means
|
||||||
* a value → [Minutes].
|
* 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
|
!containsKey(id) -> ReminderOverride.Inherit
|
||||||
this[id] == null -> ReminderOverride.None
|
getValue(id).isEmpty() -> ReminderOverride.None
|
||||||
else -> ReminderOverride.Minutes(getValue(id)!!)
|
else -> ReminderOverride.Minutes(getValue(id))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Apply [override] for [id] to this override map ([Inherit] removes the key). */
|
/** 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) {
|
when (override) {
|
||||||
ReminderOverride.Inherit -> remove(id)
|
ReminderOverride.Inherit -> remove(id)
|
||||||
ReminderOverride.None -> put(id, null)
|
ReminderOverride.None -> put(id, emptyList())
|
||||||
is ReminderOverride.Minutes -> put(id, override.minutes)
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,55 @@
|
|||||||
package de.jeanlucmakiola.floret.reminders
|
package de.jeanlucmakiola.floret.reminders
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encodes a per-target reminder-override map (`id → minutes`, where a null value
|
* Encodes a per-target reminder-override map (`id → lead times`, where an empty
|
||||||
* is an explicit "no reminder") to and from a single stored string, e.g.
|
* list is an explicit "no reminder") to and from a single stored string, e.g.
|
||||||
* `"12:30;7:none"`.
|
* `"12:30,60;7:none"`.
|
||||||
*
|
*
|
||||||
* The separators are configurable because each app already has bytes on disk in
|
* Three separators, all configurable because each app already has bytes on disk
|
||||||
* its own dialect — Agendula stores `id:minutes`, Calendula `id=minutes` — and
|
* in its own dialect and the stored format must stay stable:
|
||||||
* the stored format must stay stable. Use [DEFAULT] (Agendula's `:` / `;`) or
|
* - [entrySep] between targets (`;`)
|
||||||
* build a codec with the app's own separators; never change an app's separators
|
* - [keyValueSep] between a target id and its lead times (Agendula `:`,
|
||||||
* after release without a migration.
|
* 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(
|
class ReminderOverrideCodec(
|
||||||
private val entrySep: String = ";",
|
private val entrySep: String = ";",
|
||||||
private val keyValueSep: String = ":",
|
private val keyValueSep: String = ":",
|
||||||
|
private val listSep: String = ",",
|
||||||
private val noneToken: String = "none",
|
private val noneToken: String = "none",
|
||||||
) {
|
) {
|
||||||
/** Decode the stored string into a map (a null value = explicit no-reminder). */
|
/** Decode the stored string into a map (an empty-list value = explicit no-reminder). */
|
||||||
fun parse(stored: String?): Map<Long, Int?> {
|
fun parse(stored: String?): Map<Long, List<Int>> {
|
||||||
if (stored.isNullOrBlank()) return emptyMap()
|
if (stored.isNullOrBlank()) return emptyMap()
|
||||||
return stored.split(entrySep).mapNotNull { entry ->
|
return stored.split(entrySep).mapNotNull { entry ->
|
||||||
val parts = entry.split(keyValueSep).takeIf { it.size == 2 } ?: return@mapNotNull null
|
val parts = entry.split(keyValueSep).takeIf { it.size == 2 } ?: return@mapNotNull null
|
||||||
val id = parts[0].toLongOrNull() ?: 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
|
when (val value = parts[1]) {
|
||||||
id to value
|
noneToken -> id to emptyList()
|
||||||
|
else -> value.split(listSep).mapNotNull { it.trim().toIntOrNull() }
|
||||||
|
.normalizeReminders().takeIf { it.isNotEmpty() }?.let { id to it }
|
||||||
|
?: return@mapNotNull null
|
||||||
|
}
|
||||||
}.toMap()
|
}.toMap()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Encode the map back to its stored string form. */
|
/** Encode the map back to its stored string form. */
|
||||||
fun serialize(map: Map<Long, Int?>): String =
|
fun serialize(map: Map<Long, List<Int>>): String =
|
||||||
map.entries.joinToString(entrySep) { (id, minutes) -> "$id$keyValueSep${minutes ?: noneToken}" }
|
map.entries.joinToString(entrySep) { (id, minutes) ->
|
||||||
|
val value =
|
||||||
|
if (minutes.isEmpty()) noneToken
|
||||||
|
else minutes.normalizeReminders().joinToString(listSep) { it.toString() }
|
||||||
|
"$id$keyValueSep$value"
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/** Agendula's dialect: `id:minutes` entries joined by `;`. */
|
/** Agendula's dialect: `id:minutes` entries joined by `;`. */
|
||||||
|
|||||||
@@ -21,32 +21,47 @@ class ReminderModelTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `reminderLeadFor distinguishes absent, explicit-none and a value`() {
|
fun `normalize dedupes and sorts`() {
|
||||||
val map = mapOf(1L to 30, 2L to null)
|
assertThat(listOf(60, 10, 60, 0).normalizeReminders()).containsExactly(0, 10, 60).inOrder()
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `reminderOverrideFor maps absent, null and value to the choice types`() {
|
fun `reminderLeadsFor distinguishes absent, explicit-none and a value`() {
|
||||||
val map = mapOf(1L to 30, 2L to null)
|
val map = mapOf(1L to listOf(30, 60), 2L to emptyList())
|
||||||
assertThat(map.reminderOverrideFor(1L)).isEqualTo(ReminderOverride.Minutes(30))
|
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(2L)).isEqualTo(ReminderOverride.None)
|
||||||
assertThat(map.reminderOverrideFor(3L)).isEqualTo(ReminderOverride.Inherit)
|
assertThat(map.reminderOverrideFor(3L)).isEqualTo(ReminderOverride.Inherit)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `applyReminderOverride writes, clears and removes entries`() {
|
fun `applyReminderOverride writes normalized, clears and removes entries`() {
|
||||||
val map = mutableMapOf<Long, Int?>()
|
val map = mutableMapOf<Long, List<Int>>()
|
||||||
map.applyReminderOverride(1L, ReminderOverride.Minutes(45))
|
map.applyReminderOverride(1L, ReminderOverride.Minutes(listOf(60, 45, 45)))
|
||||||
assertThat(map).containsExactly(1L, 45)
|
assertThat(map.getValue(1L)).containsExactly(45, 60).inOrder() // deduped + sorted
|
||||||
|
|
||||||
map.applyReminderOverride(1L, ReminderOverride.None)
|
map.applyReminderOverride(1L, ReminderOverride.None)
|
||||||
assertThat(map[1L]).isNull()
|
assertThat(map.getValue(1L)).isEmpty()
|
||||||
assertThat(map).containsKey(1L)
|
assertThat(map).containsKey(1L)
|
||||||
|
|
||||||
map.applyReminderOverride(1L, ReminderOverride.Inherit)
|
map.applyReminderOverride(1L, ReminderOverride.Inherit)
|
||||||
assertThat(map).doesNotContainKey(1L)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ class ReminderOverrideCodecTest {
|
|||||||
private val codec = ReminderOverrideCodec.DEFAULT
|
private val codec = ReminderOverrideCodec.DEFAULT
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `round-trips ids, minutes and explicit none`() {
|
fun `round-trips ids, multi-value minutes and explicit none`() {
|
||||||
val map = mapOf(12L to 30, 7L to null, 3L to 0)
|
val map = mapOf(12L to listOf(30, 60), 7L to emptyList(), 3L to listOf(0))
|
||||||
assertThat(codec.parse(codec.serialize(map))).isEqualTo(map)
|
assertThat(codec.parse(codec.serialize(map))).isEqualTo(map)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,24 +26,33 @@ class ReminderOverrideCodecTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `none token decodes to a null value`() {
|
fun `none token decodes to an empty-list value`() {
|
||||||
assertThat(codec.parse("7:none")).isEqualTo(mapOf(7L to null))
|
assertThat(codec.parse("7:none")).isEqualTo(mapOf(7L to emptyList<Int>()))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `garbage entries are dropped, valid ones kept`() {
|
fun `a legacy single value round-trips byte-identically`() {
|
||||||
assertThat(codec.parse("12:30;garbage;x:5;9:notnum;4:10"))
|
// Pre-multi-reminders each target stored one value ("30"); it must still
|
||||||
.isEqualTo(mapOf(12L to 30, 4L to 10))
|
// 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
|
@Test
|
||||||
fun `a custom dialect keeps a separate on-disk format`() {
|
fun `a custom dialect keeps a separate on-disk format`() {
|
||||||
// Calendula's dialect: id=minutes entries.
|
// Calendula's dialect: id=minutes entries.
|
||||||
val calendula = ReminderOverrideCodec(entrySep = ";", keyValueSep = "=", noneToken = "none")
|
val calendula = ReminderOverrideCodec(entrySep = ";", keyValueSep = "=", listSep = ",", noneToken = "none")
|
||||||
val map = mapOf(12L to 30, 7L to null)
|
val map = mapOf(12L to listOf(30, 60), 7L to emptyList())
|
||||||
val stored = calendula.serialize(map)
|
val stored = calendula.serialize(map)
|
||||||
|
|
||||||
assertThat(stored).contains("12=30")
|
assertThat(stored).contains("12=30,60")
|
||||||
assertThat(stored).contains("7=none")
|
assertThat(stored).contains("7=none")
|
||||||
assertThat(calendula.parse(stored)).isEqualTo(map)
|
assertThat(calendula.parse(stored)).isEqualTo(map)
|
||||||
// The Agendula dialect cannot read Calendula's bytes — the formats are distinct.
|
// The Agendula dialect cannot read Calendula's bytes — the formats are distinct.
|
||||||
|
|||||||
Reference in New Issue
Block a user