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:
32
core-reminders/build.gradle.kts
Normal file
32
core-reminders/build.gradle.kts
Normal 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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user