Merge pull request 'feat(core-reminders): reminder leads that count from the start' (#19) from feat/reminder-leads into main

Reviewed-on: https://codeberg.org/jlmakiola/floret-kit/pulls/19
This commit is contained in:
Jean-Luc Makiola
2026-10-21 17:01:11 +02:00
5 changed files with 165 additions and 3 deletions
+10
View File
@@ -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
@@ -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<ReminderLead>.normalizeReminders(): List<ReminderLead> =
distinct().sortedWith(compareBy({ !it.fromStart }, { it.minutes }))
/** Each lead as one counting back from the due/end date. */
fun List<Int>.asReminderLeads(): List<ReminderLead> = map { ReminderLead(it) }
/** As [reminderOverrideFor], for an override map that names each lead's date. */
@JvmName("reminderLeadOverrideFor")
fun Map<Long, List<ReminderLead>>.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<Long, List<ReminderLead>>.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<ReminderLead>, allowInherit: Boolean): ReminderOverride {
val norm = leads.normalizeReminders()
return when {
norm.isNotEmpty() -> ReminderOverride.Minutes.of(norm)
allowInherit -> ReminderOverride.Inherit
else -> ReminderOverride.None
}
}
@@ -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<Int>) : 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<Int>, val fromStart: List<Int> = emptyList()) : ReminderOverride {
/** Every lead, naming the date it counts from. */
val leads: List<ReminderLead>
get() = fromStart.map { ReminderLead(it, fromStart = true) } + minutes.asReminderLeads()
companion object {
/** [leads] split by the date they count from. */
fun of(leads: List<ReminderLead>): 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<Int>.normalizeReminders(): List<Int> = distinct().sorted()
* means no reminder. Mirrors the absent/empty/value semantics of the override
* map.
*/
fun Map<Long, List<Int>>.reminderLeadsFor(id: Long, default: List<Int>): List<Int> =
fun <T> Map<Long, List<T>>.reminderLeadsFor(id: Long, default: List<T>): List<T> =
if (containsKey(id)) getValue(id) else default
/**
@@ -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<Long, List<ReminderLead>> {
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<Long, List<ReminderLead>>): 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<Long, List<Int>>): String =
map.entries.joinToString(entrySep) { (id, minutes) ->
@@ -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<Long, List<ReminderLead>>()
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)
}
}