From 367a8ff8afa5f78e022e57623c21c386c961676b Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 28 Jun 2026 14:11:55 +0200 Subject: [PATCH] core-reminders: shared reminder-lead plumbing (ReminderUnit, override model + codec) Pure-Kotlin module, drawn from the duplicate reminder logic in Agendula and Calendula: the lead-time unit model (ReminderUnit / decomposeReminderMinutes), the per-target override model (ReminderOverride + reminderLeadFor / applyReminderOverride) and the stored-format codec (ReminderOverrideCodec). The codec's separators are configurable so each app keeps its on-disk format (Agendula stores id:minutes, Calendula id=minutes) without a data migration. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 3 +- core-reminders/build.gradle.kts | 32 ++++++++++++ .../floret/reminders/ReminderOverride.kt | 44 ++++++++++++++++ .../floret/reminders/ReminderOverrideCodec.kt | 38 ++++++++++++++ .../floret/reminders/ReminderUnit.kt | 31 +++++++++++ .../floret/reminders/ReminderModelTest.kt | 52 +++++++++++++++++++ .../reminders/ReminderOverrideCodecTest.kt | 52 +++++++++++++++++++ settings.gradle.kts | 1 + 8 files changed, 252 insertions(+), 1 deletion(-) create mode 100644 core-reminders/build.gradle.kts create mode 100644 core-reminders/src/main/kotlin/de/jeanlucmakiola/floret/reminders/ReminderOverride.kt create mode 100644 core-reminders/src/main/kotlin/de/jeanlucmakiola/floret/reminders/ReminderOverrideCodec.kt create mode 100644 core-reminders/src/main/kotlin/de/jeanlucmakiola/floret/reminders/ReminderUnit.kt create mode 100644 core-reminders/src/test/kotlin/de/jeanlucmakiola/floret/reminders/ReminderModelTest.kt create mode 100644 core-reminders/src/test/kotlin/de/jeanlucmakiola/floret/reminders/ReminderOverrideCodecTest.kt diff --git a/README.md b/README.md index cc99fbb..9c7665c 100644 --- a/README.md +++ b/README.md @@ -32,8 +32,9 @@ local source module — edit the kit and the app picks it up immediately. | Module | What it holds | |--------|---------------| | `core-time` | Pure-Kotlin date/time helpers: local-day windows (`DayWindow`) for smart-list logic and locale/zone-aware display formatting (`Instant.formatDate()` / `formatTime()` / `formatDateTime()`). No Android, no deps. | +| `core-reminders` | Pure-Kotlin reminder-lead plumbing: the lead-time unit model (`ReminderUnit`, `decomposeReminderMinutes`), the per-target override model (`ReminderOverride` + `reminderLeadFor` / `applyReminderOverride`) and the stored-format codec (`ReminderOverrideCodec`, separators configurable per app). No Android, no deps. Each app layers its own DataStore + string labels on top. | -_More modules (identity/theme, components, screen recipes, provider/prefs/reminders/crash plumbing) land as they're extracted from the apps._ +_More modules (identity/theme, components, screen recipes, provider/prefs/crash plumbing) land as they're extracted from the apps._ ## Build diff --git a/core-reminders/build.gradle.kts b/core-reminders/build.gradle.kts new file mode 100644 index 0000000..afe1e4d --- /dev/null +++ b/core-reminders/build.gradle.kts @@ -0,0 +1,32 @@ +// core-reminders — pure-Kotlin reminder-lead plumbing shared across the family: +// the lead-time unit model (ReminderUnit / decomposeReminderMinutes), the +// per-target override model (ReminderOverride + resolution helpers) and the +// stored-format codec (ReminderOverrideCodec, separators configurable so each +// app keeps its on-disk format). No Android, no third-party deps — a plain JVM +// library, like core-time; each app layers its own DataStore + string labels on +// top. +plugins { + alias(libs.plugins.kotlin.jvm) +} + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +dependencies { + testImplementation(libs.junit.jupiter.api) + testRuntimeOnly(libs.junit.jupiter.engine) + testRuntimeOnly(libs.junit.platform.launcher) + testImplementation(libs.truth) +} + +tasks.test { + useJUnitPlatform() +} 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 new file mode 100644 index 0000000..af4c539 --- /dev/null +++ b/core-reminders/src/main/kotlin/de/jeanlucmakiola/floret/reminders/ReminderOverride.kt @@ -0,0 +1,44 @@ +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. + */ +sealed interface ReminderOverride { + /** No override — the target uses the global default. */ + data object Inherit : 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 +} + +/** + * 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 + +/** + * Read [id]'s entry as a [ReminderOverride]: absent → [Inherit], null → [None], + * a value → [Minutes]. + */ +fun Map.reminderOverrideFor(id: Long): ReminderOverride = when { + !containsKey(id) -> ReminderOverride.Inherit + this[id] == null -> 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) { + when (override) { + ReminderOverride.Inherit -> remove(id) + ReminderOverride.None -> put(id, null) + is ReminderOverride.Minutes -> put(id, override.minutes) + } +} 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 new file mode 100644 index 0000000..5033c06 --- /dev/null +++ b/core-reminders/src/main/kotlin/de/jeanlucmakiola/floret/reminders/ReminderOverrideCodec.kt @@ -0,0 +1,38 @@ +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"`. + * + * 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. + */ +class ReminderOverrideCodec( + private val entrySep: String = ";", + private val keyValueSep: String = ":", + private val noneToken: String = "none", +) { + /** Decode the stored string into a map (a null 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 + }.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}" } + + companion object { + /** Agendula's dialect: `id:minutes` entries joined by `;`. */ + val DEFAULT = ReminderOverrideCodec() + } +} diff --git a/core-reminders/src/main/kotlin/de/jeanlucmakiola/floret/reminders/ReminderUnit.kt b/core-reminders/src/main/kotlin/de/jeanlucmakiola/floret/reminders/ReminderUnit.kt new file mode 100644 index 0000000..81e5fd8 --- /dev/null +++ b/core-reminders/src/main/kotlin/de/jeanlucmakiola/floret/reminders/ReminderUnit.kt @@ -0,0 +1,31 @@ +package de.jeanlucmakiola.floret.reminders + +/** The unit of a custom reminder lead time; [minutesFactor] converts to minutes. */ +enum class ReminderUnit(val minutesFactor: Int) { + Minutes(1), + Hours(60), + Days(1_440), + Weeks(10_080), +} + +/** + * A reminder lead time broken into a whole [amount] of a [unit] — the seed for a + * custom-amount editor. [amount] is null when there is no lead time to show. + */ +data class ReminderAmount(val amount: Int?, val unit: ReminderUnit) + +/** + * Decompose a reminder lead time (minutes before due) into the largest unit that + * divides it evenly: 120 → 2 hours, 1 440 → 1 day, 10 080 → 1 week, 90 → 90 + * minutes. null or a non-positive lead time → no amount, defaulting to minutes. + */ +fun decomposeReminderMinutes(minutes: Int?): ReminderAmount = when { + minutes == null || minutes <= 0 -> ReminderAmount(null, ReminderUnit.Minutes) + minutes % ReminderUnit.Weeks.minutesFactor == 0 -> + ReminderAmount(minutes / ReminderUnit.Weeks.minutesFactor, ReminderUnit.Weeks) + minutes % ReminderUnit.Days.minutesFactor == 0 -> + ReminderAmount(minutes / ReminderUnit.Days.minutesFactor, ReminderUnit.Days) + minutes % ReminderUnit.Hours.minutesFactor == 0 -> + ReminderAmount(minutes / ReminderUnit.Hours.minutesFactor, ReminderUnit.Hours) + else -> ReminderAmount(minutes, ReminderUnit.Minutes) +} 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 new file mode 100644 index 0000000..057d79d --- /dev/null +++ b/core-reminders/src/test/kotlin/de/jeanlucmakiola/floret/reminders/ReminderModelTest.kt @@ -0,0 +1,52 @@ +package de.jeanlucmakiola.floret.reminders + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class ReminderModelTest { + + @Test + fun `decompose picks the largest whole unit`() { + assertThat(decomposeReminderMinutes(120)).isEqualTo(ReminderAmount(2, ReminderUnit.Hours)) + assertThat(decomposeReminderMinutes(1_440)).isEqualTo(ReminderAmount(1, ReminderUnit.Days)) + assertThat(decomposeReminderMinutes(10_080)).isEqualTo(ReminderAmount(1, ReminderUnit.Weeks)) + assertThat(decomposeReminderMinutes(90)).isEqualTo(ReminderAmount(90, ReminderUnit.Minutes)) + } + + @Test + fun `decompose of null or non-positive has no amount`() { + assertThat(decomposeReminderMinutes(null)).isEqualTo(ReminderAmount(null, ReminderUnit.Minutes)) + assertThat(decomposeReminderMinutes(0)).isEqualTo(ReminderAmount(null, ReminderUnit.Minutes)) + assertThat(decomposeReminderMinutes(-5)).isEqualTo(ReminderAmount(null, ReminderUnit.Minutes)) + } + + @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 + } + + @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)) + 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) + + map.applyReminderOverride(1L, ReminderOverride.None) + assertThat(map[1L]).isNull() + assertThat(map).containsKey(1L) + + map.applyReminderOverride(1L, ReminderOverride.Inherit) + assertThat(map).doesNotContainKey(1L) + } +} 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 new file mode 100644 index 0000000..def04ab --- /dev/null +++ b/core-reminders/src/test/kotlin/de/jeanlucmakiola/floret/reminders/ReminderOverrideCodecTest.kt @@ -0,0 +1,52 @@ +package de.jeanlucmakiola.floret.reminders + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +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) + assertThat(codec.parse(codec.serialize(map))).isEqualTo(map) + } + + @Test + fun `null or blank parses to empty`() { + assertThat(codec.parse(null)).isEmpty() + assertThat(codec.parse("")).isEmpty() + assertThat(codec.parse(" ")).isEmpty() + } + + @Test + fun `empty map serializes to empty string`() { + assertThat(codec.serialize(emptyMap())).isEmpty() + } + + @Test + fun `none token decodes to a null value`() { + assertThat(codec.parse("7:none")).isEqualTo(mapOf(7L to null)) + } + + @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)) + } + + @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 stored = calendula.serialize(map) + + assertThat(stored).contains("12=30") + assertThat(stored).contains("7=none") + assertThat(calendula.parse(stored)).isEqualTo(map) + // The Agendula dialect cannot read Calendula's bytes — the formats are distinct. + assertThat(codec.parse(stored)).isEmpty() + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index c1fad67..bf30751 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -32,6 +32,7 @@ dependencyResolutionManagement { rootProject.name = "floret-kit" include(":core-time") +include(":core-reminders") include(":core-crash") include(":identity") include(":components")