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) <noreply@anthropic.com>
This commit is contained in:
2026-06-28 14:11:55 +02:00
parent 0db0e3883f
commit 367a8ff8af
8 changed files with 252 additions and 1 deletions

View File

@@ -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

View File

@@ -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()
}

View File

@@ -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<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],
* a value → [Minutes].
*/
fun Map<Long, Int?>.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<Long, Int?>.applyReminderOverride(id: Long, override: ReminderOverride) {
when (override) {
ReminderOverride.Inherit -> remove(id)
ReminderOverride.None -> put(id, null)
is ReminderOverride.Minutes -> put(id, override.minutes)
}
}

View File

@@ -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<Long, 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
}.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}" }
companion object {
/** Agendula's dialect: `id:minutes` entries joined by `;`. */
val DEFAULT = ReminderOverrideCodec()
}
}

View File

@@ -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)
}

View File

@@ -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<Long, Int?>()
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)
}
}

View File

@@ -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()
}
}

View File

@@ -32,6 +32,7 @@ dependencyResolutionManagement {
rootProject.name = "floret-kit"
include(":core-time")
include(":core-reminders")
include(":core-crash")
include(":identity")
include(":components")