diff --git a/core-reminders/src/main/kotlin/de/jeanlucmakiola/floret/reminders/ReminderOverride.kt b/core-reminders/src/main/kotlin/de/jeanlucmakiola/floret/reminders/ReminderOverride.kt index af4c539..a2f7b80 100644 --- a/core-reminders/src/main/kotlin/de/jeanlucmakiola/floret/reminders/ReminderOverride.kt +++ b/core-reminders/src/main/kotlin/de/jeanlucmakiola/floret/reminders/ReminderOverride.kt @@ -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) : 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.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.normalizeReminders(): List = 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.reminderOverrideFor(id: Long): ReminderOverride = when { +fun Map>.reminderLeadsFor(id: Long, default: List): List = + 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>.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.applyReminderOverride(id: Long, override: ReminderOverride) { +fun MutableMap>.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, allowInherit: Boolean): ReminderOverride { + val norm = minutes.normalizeReminders() + return when { + norm.isNotEmpty() -> ReminderOverride.Minutes(norm) + allowInherit -> ReminderOverride.Inherit + else -> ReminderOverride.None } } diff --git a/core-reminders/src/main/kotlin/de/jeanlucmakiola/floret/reminders/ReminderOverrideCodec.kt b/core-reminders/src/main/kotlin/de/jeanlucmakiola/floret/reminders/ReminderOverrideCodec.kt index 5033c06..478c64d 100644 --- a/core-reminders/src/main/kotlin/de/jeanlucmakiola/floret/reminders/ReminderOverrideCodec.kt +++ b/core-reminders/src/main/kotlin/de/jeanlucmakiola/floret/reminders/ReminderOverrideCodec.kt @@ -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 { + /** Decode the stored string into a map (an empty-list value = explicit no-reminder). */ + fun parse(stored: String?): Map> { 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): String = - map.entries.joinToString(entrySep) { (id, minutes) -> "$id$keyValueSep${minutes ?: noneToken}" } + fun serialize(map: Map>): 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 `;`. */ diff --git a/core-reminders/src/test/kotlin/de/jeanlucmakiola/floret/reminders/ReminderModelTest.kt b/core-reminders/src/test/kotlin/de/jeanlucmakiola/floret/reminders/ReminderModelTest.kt index 057d79d..29d4399 100644 --- a/core-reminders/src/test/kotlin/de/jeanlucmakiola/floret/reminders/ReminderModelTest.kt +++ b/core-reminders/src/test/kotlin/de/jeanlucmakiola/floret/reminders/ReminderModelTest.kt @@ -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() - map.applyReminderOverride(1L, ReminderOverride.Minutes(45)) - assertThat(map).containsExactly(1L, 45) + fun `applyReminderOverride writes normalized, clears and removes entries`() { + val map = mutableMapOf>() + 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) + } } diff --git a/core-reminders/src/test/kotlin/de/jeanlucmakiola/floret/reminders/ReminderOverrideCodecTest.kt b/core-reminders/src/test/kotlin/de/jeanlucmakiola/floret/reminders/ReminderOverrideCodecTest.kt index def04ab..36afe2d 100644 --- a/core-reminders/src/test/kotlin/de/jeanlucmakiola/floret/reminders/ReminderOverrideCodecTest.kt +++ b/core-reminders/src/test/kotlin/de/jeanlucmakiola/floret/reminders/ReminderOverrideCodecTest.kt @@ -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())) } @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.