From a34a74fb8b7e67dd10f0e66d1ac094192707a54a Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 21 Oct 2026 16:54:07 +0200 Subject: [PATCH] feat(core-reminders): reminder leads that count from the start Lets an app's reminder defaults name whether each lead counts back from the start or the due/end date. Additive; minute-only callers are unchanged. --- CHANGELOG.md | 10 ++++ .../floret/reminders/ReminderLead.kt | 60 +++++++++++++++++++ .../floret/reminders/ReminderOverride.kt | 24 +++++++- .../floret/reminders/ReminderOverrideCodec.kt | 27 +++++++++ .../floret/reminders/ReminderLeadTest.kt | 47 +++++++++++++++ 5 files changed, 165 insertions(+), 3 deletions(-) create mode 100644 core-reminders/src/main/kotlin/de/jeanlucmakiola/floret/reminders/ReminderLead.kt create mode 100644 core-reminders/src/test/kotlin/de/jeanlucmakiola/floret/reminders/ReminderLeadTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 9caee90..a6a1dc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,16 @@ are no binary releases; "version" tracks the shared `version` in the root build ## [Unreleased] +### Added + +- **`core-reminders`** — reminders can count back from a target's start as + well as its due/end date. `ReminderLead(minutes, fromStart)`, an optional + `fromStart` list on `ReminderOverride.Minutes` (with `leads` and + `Minutes.of(leads)`), lead-aware `ReminderOverrideCodec.parseLeads` / + `serializeLeads` (start leads stored as `s30`, plain values still read as + before due) and lead versions of the override-map helpers. Additive: + minute-only callers are unchanged. + ### Fixed - **`core-locale`** — picking a language did nothing on Android 13+. AppCompat's diff --git a/core-reminders/src/main/kotlin/de/jeanlucmakiola/floret/reminders/ReminderLead.kt b/core-reminders/src/main/kotlin/de/jeanlucmakiola/floret/reminders/ReminderLead.kt new file mode 100644 index 0000000..d580426 --- /dev/null +++ b/core-reminders/src/main/kotlin/de/jeanlucmakiola/floret/reminders/ReminderLead.kt @@ -0,0 +1,60 @@ +package de.jeanlucmakiola.floret.reminders + +/** + * One reminder lead time and the date it counts back from: the target's start + * when [fromStart], otherwise its due/end date. An app whose targets have one + * date only never sets [fromStart], and its leads are plain minutes. + */ +data class ReminderLead(val minutes: Int, val fromStart: Boolean = false) { + + /** The stored form: `30` before due, `s30` before start. */ + fun toToken(): String = if (fromStart) "$START_PREFIX$minutes" else minutes.toString() + + companion object { + private const val START_PREFIX = "s" + + /** [toToken] read back, or null when [token] is not one. */ + fun parse(token: String): ReminderLead? { + val text = token.trim() + val fromStart = text.startsWith(START_PREFIX) + val minutes = text.removePrefix(START_PREFIX).toIntOrNull() ?: return null + return ReminderLead(minutes, fromStart) + } + } +} + +/** Distinct, start's first, then ascending — the canonical form stored and resolved. */ +@JvmName("normalizeReminderLeads") +fun List.normalizeReminders(): List = + distinct().sortedWith(compareBy({ !it.fromStart }, { it.minutes })) + +/** Each lead as one counting back from the due/end date. */ +fun List.asReminderLeads(): List = map { ReminderLead(it) } + +/** As [reminderOverrideFor], for an override map that names each lead's date. */ +@JvmName("reminderLeadOverrideFor") +fun Map>.reminderOverrideFor(id: Long): ReminderOverride = when { + !containsKey(id) -> ReminderOverride.Inherit + getValue(id).isEmpty() -> ReminderOverride.None + else -> ReminderOverride.Minutes.of(getValue(id)) +} + +/** As [applyReminderOverride], for an override map that names each lead's date. */ +@JvmName("applyReminderLeadOverride") +fun MutableMap>.applyReminderOverride(id: Long, override: ReminderOverride) { + when (override) { + ReminderOverride.Inherit -> remove(id) + ReminderOverride.None -> put(id, emptyList()) + is ReminderOverride.Minutes -> put(id, override.leads.normalizeReminders()) + } +} + +/** As [reminderOverrideForMinutes], for leads that name their date. */ +fun reminderOverrideForLeads(leads: List, allowInherit: Boolean): ReminderOverride { + val norm = leads.normalizeReminders() + return when { + norm.isNotEmpty() -> ReminderOverride.Minutes.of(norm) + allowInherit -> ReminderOverride.Inherit + else -> ReminderOverride.None + } +} 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 36d86de..165fc30 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 @@ -15,8 +15,26 @@ sealed interface ReminderOverride { /** Explicit "no reminder" for this target, regardless of the global default. */ data object None : ReminderOverride - /** Specific lead times in minutes before due (non-empty, distinct, ascending). */ - data class Minutes(val minutes: List) : ReminderOverride + /** + * Specific lead times in minutes before due (non-empty, distinct, ascending), + * plus those counting back from the start instead ([fromStart], same form). + */ + data class Minutes(val minutes: List, val fromStart: List = emptyList()) : ReminderOverride { + /** Every lead, naming the date it counts from. */ + val leads: List + get() = fromStart.map { ReminderLead(it, fromStart = true) } + minutes.asReminderLeads() + + companion object { + /** [leads] split by the date they count from. */ + fun of(leads: List): Minutes { + val norm = leads.normalizeReminders() + return Minutes( + minutes = norm.filter { !it.fromStart }.map { it.minutes }, + fromStart = norm.filter { it.fromStart }.map { it.minutes }, + ) + } + } + } } /** The lead times of a [ReminderOverride.Minutes]; empty for [ReminderOverride.Inherit] and [ReminderOverride.None]. */ @@ -31,7 +49,7 @@ fun List.normalizeReminders(): List = distinct().sorted() * means no reminder. Mirrors the absent/empty/value semantics of the override * map. */ -fun Map>.reminderLeadsFor(id: Long, default: List): List = +fun Map>.reminderLeadsFor(id: Long, default: List): List = if (containsKey(id)) getValue(id) else default /** 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 478c64d..2332bd2 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 @@ -42,6 +42,33 @@ class ReminderOverrideCodec( }.toMap() } + /** + * As [parse], for a map whose leads name their date ([ReminderLead.toToken]). + * A value written by [serialize] reads as leads counting back from due. + */ + fun parseLeads(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 + when (val value = parts[1]) { + noneToken -> id to emptyList() + else -> value.split(listSep).mapNotNull(ReminderLead::parse) + .normalizeReminders().takeIf { it.isNotEmpty() }?.let { id to it } + ?: return@mapNotNull null + } + }.toMap() + } + + /** Encode a map read by [parseLeads] back to its stored string form. */ + fun serializeLeads(map: Map>): String = + map.entries.joinToString(entrySep) { (id, leads) -> + val value = + if (leads.isEmpty()) noneToken + else leads.normalizeReminders().joinToString(listSep) { it.toToken() } + "$id$keyValueSep$value" + } + /** Encode the map back to its stored string form. */ fun serialize(map: Map>): String = map.entries.joinToString(entrySep) { (id, minutes) -> diff --git a/core-reminders/src/test/kotlin/de/jeanlucmakiola/floret/reminders/ReminderLeadTest.kt b/core-reminders/src/test/kotlin/de/jeanlucmakiola/floret/reminders/ReminderLeadTest.kt new file mode 100644 index 0000000..2a99ccd --- /dev/null +++ b/core-reminders/src/test/kotlin/de/jeanlucmakiola/floret/reminders/ReminderLeadTest.kt @@ -0,0 +1,47 @@ +package de.jeanlucmakiola.floret.reminders + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class ReminderLeadTest { + + private val codec = ReminderOverrideCodec.DEFAULT + + @Test + fun `round-trips leads that name their date`() { + val map = mapOf( + 12L to listOf(ReminderLead(1_440, fromStart = true), ReminderLead(30)), + 7L to emptyList(), + 3L to listOf(ReminderLead(-540, fromStart = true)), + ) + assertThat(codec.parseLeads(codec.serializeLeads(map))).isEqualTo(map) + } + + @Test + fun `a value written as plain minutes reads as leads before due`() { + val stored = codec.serialize(mapOf(12L to listOf(30, 60))) + assertThat(codec.parseLeads(stored)).isEqualTo(mapOf(12L to listOf(ReminderLead(30), ReminderLead(60)))) + } + + @Test + fun `an override splits its leads by date`() { + val override = ReminderOverride.Minutes.of(listOf(ReminderLead(60), ReminderLead(10, fromStart = true), ReminderLead(0))) + assertThat(override).isEqualTo(ReminderOverride.Minutes(minutes = listOf(0, 60), fromStart = listOf(10))) + assertThat(override.leads).containsExactly(ReminderLead(10, fromStart = true), ReminderLead(0), ReminderLead(60)).inOrder() + } + + @Test + fun `an override map of leads applies and reads back`() { + val map = mutableMapOf>() + map.applyReminderOverride(1L, ReminderOverride.Minutes(listOf(30), fromStart = listOf(5))) + assertThat(map.reminderOverrideFor(1L)).isEqualTo(ReminderOverride.Minutes(listOf(30), fromStart = listOf(5))) + map.applyReminderOverride(1L, ReminderOverride.None) + assertThat(map.reminderOverrideFor(1L)).isEqualTo(ReminderOverride.None) + } + + @Test + fun `an empty choice of leads falls back as the minutes one does`() { + assertThat(reminderOverrideForLeads(emptyList(), allowInherit = true)).isEqualTo(ReminderOverride.Inherit) + assertThat(reminderOverrideForLeads(emptyList(), allowInherit = false)).isEqualTo(ReminderOverride.None) + } +}