Moving a past start or due later brings its default reminders (#121) (#133)
Beta — Codeberg pre-release / detect (push) Successful in 8s
Beta — Codeberg pre-release / beta (push) Skipped

A one-off task saved with a start of "now" (the "Default start to today" setting, or the picker's default time) leaves out the "at start" default, since it would ring at once. Moving that start later afterwards never brought it back: the task already had a start, so the edit didn't count as gaining one.

`StoredReminders.withDefaults` now also brings a date's defaults when that date moves and all of its defaults had already rung at the old date (checked with `ReminderPlanner.triggerAt`, so all-day dates go by their calendar day). Only when:
- the task is one-off and has reminders of its own, none from that date — a task with no reminders keeps following the planner's defaults, and one with reminders turned off stays off;
- the date really moved — flipping all-day on the same day doesn't count.

Applies to start and due alike. The editor passes the loaded all-day flag so a kind switch is told apart from a move.

Deviation from the issue: creating a task with a future start already took the start default (checked on the emulator); what the reporter hit is editing a task saved with a start of "now". Known trade-off: a reminder from a date removed by hand comes back if that date is moved after its default would have rung — stored data can't tell the two apart.

Closes #121

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/133
This commit is contained in:
Jean-Luc Makiolaandmakiolaj committed 2026-11-02 20:58:47 +01:00
1 parent c3812e7d94
commit 4cd9e397cd
4 files changed
+121 -4

No files matched your search

+3
View File
@@ -35,6 +35,9 @@ All notable changes to this project are documented here. The format follows
upcoming reminder and clears one already showing (#127, #129).
- The splash screen follows the app's theme, including Black and dynamic
colour, instead of flashing white in dark mode (#119).
- A task saved without its default start or due reminder because it had
already rung (a start of "now", say) gets it once that date is moved later,
if the task has no reminder from that date (#121).
## [1.2.0] - 2026-10-26
@@ -5,6 +5,7 @@ import de.jeanlucmakiola.agendula.data.reminders.ReminderPlanner
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskReminder
import de.jeanlucmakiola.agendula.domain.calendarDate
import java.time.ZoneId
import kotlin.time.Instant
@@ -22,7 +23,8 @@ internal object StoredReminders {
*
* A task getting its first date takes the defaults only when the form
* names no reminder; one that already had a date takes those of the new
* date that no reminder of the form counts from. Otherwise the form is
* date that no reminder of the form counts from, and those of a date
* whose defaults had all rung when it moved — see [revived]. Otherwise the form is
* kept, so an edit that clears every reminder means "use the default
* here" as before.
*
@@ -33,7 +35,7 @@ internal object StoredReminders {
fun of(form: TaskForm, current: Task?, settings: Settings, now: Long, zone: ZoneId): List<TaskReminder> {
val wanted = form.reminders.map { it.lead }.distinct()
if (form.remindersChosen) return wanted
return withDefaults(form, current?.start, current?.due, settings, now, zone)
return withDefaults(form, current?.start, current?.due, current?.isAllDay ?: form.isAllDay, settings, now, zone)
}
/** [of] for a task whose dates were [start] and [due], whatever the form chose. */
@@ -41,6 +43,7 @@ internal object StoredReminders {
form: TaskForm,
start: Instant?,
due: Instant?,
wasAllDay: Boolean,
settings: Settings,
now: Long,
zone: ZoneId,
@@ -48,10 +51,12 @@ internal object StoredReminders {
val wanted = form.reminders.map { it.lead }.distinct()
val had = anchorsOf(start, due)
val gained = anchorsOf(form.start, form.due) - had
if (gained.isEmpty()) return wanted
val revived = revived(form, start, due, wasAllDay, wanted, settings, now, zone)
if (gained.isEmpty() && revived.isEmpty()) return wanted
if (had.isEmpty() || wanted.isEmpty()) return wanted.ifEmpty { defaultsFor(form, settings, now, zone) }
val named = wanted.map { it.fromStart }.toSet()
return wanted + defaultsFor(form, settings, now, zone).filter { it.fromStart in gained && it.fromStart !in named }
val fresh = gained + revived
return wanted + defaultsFor(form, settings, now, zone).filter { it.fromStart in fresh && it.fromStart !in named }
}
/**
@@ -79,6 +84,41 @@ internal object StoredReminders {
return kept.distinct().ifEmpty { defaultsFor(form, settings, now, zone) }
}
/**
* The dates of a one-off task that moved after every default of theirs
* had rung: a task saved then was left without them as past (#121), so
* the new date brings them like a date the task gains. Only on a task
* with reminders of its own and none from that date — no reminders at
* all is the planner's default or turned off, and either holds.
*/
private fun revived(
form: TaskForm,
start: Instant?,
due: Instant?,
wasAllDay: Boolean,
wanted: List<TaskReminder>,
settings: Settings,
now: Long,
zone: ZoneId,
): Set<Boolean> {
if (form.rrule != null || wanted.isEmpty()) return emptySet()
val named = wanted.map { it.fromStart }.toSet()
val defaults = settings.defaultRemindersFor(form.listId, wasAllDay)
fun revived(fromStart: Boolean, was: Instant?, isNow: Instant?): Boolean {
if (was == null || isNow == null || fromStart in named) return false
val moved = if (wasAllDay == form.isAllDay) {
isNow != was
} else {
isNow.calendarDate(form.isAllDay, zone) != was.calendarDate(wasAllDay, zone)
}
val leads = defaults.filter { it.fromStart == fromStart }
return moved && leads.isNotEmpty() && leads.all {
ReminderPlanner.triggerAt(was, wasAllDay, it.minutesBefore, settings.allDayReminderMinuteOfDay, zone) < now
}
}
return setOfNotNull(true.takeIf { revived(true, start, form.start) }, false.takeIf { revived(false, due, form.due) })
}
/** The dates a reminder can count from, as [TaskReminder.fromStart]. */
private fun anchorsOf(start: Any?, due: Any?): Set<Boolean> =
setOfNotNull(true.takeIf { start != null }, false.takeIf { due != null })
@@ -147,6 +147,7 @@ class TaskEditViewModel @Inject constructor(
private var loadedReminders: List<TaskReminder> = emptyList()
private var loadedStart: Instant? = null
private var loadedDue: Instant? = null
private var loadedAllDay = false
/** Start a fresh task, optionally pre-selecting a list / parent. */
fun bindNew(
@@ -228,6 +229,7 @@ class TaskEditViewModel @Inject constructor(
storedReminders = loadedReminders.toSet()
loadedStart = task.start
loadedDue = task.due
loadedAllDay = task.isAllDay
_state.value = withFields(
TaskEditUiState(
loading = false,
@@ -362,6 +364,7 @@ class TaskEditViewModel @Inject constructor(
toForm().copy(reminders = loadedReminders),
loadedStart,
loadedDue,
loadedAllDay,
settings,
nowMillis(),
ZoneId.systemDefault(),
@@ -61,6 +61,77 @@ class StoredRemindersTest {
assertThat(stored(form.copy(reminders = listOf(hourBeforeDue)), due)).containsExactly(hourBeforeDue, dayBeforeStart)
}
private fun moved(
form: TaskForm,
start: Instant?,
due: Instant?,
wasAllDay: Boolean = form.isAllDay,
at: ZoneId = zone,
) = StoredReminders.withDefaults(form, start, due, wasAllDay, settings, now.toEpochMilliseconds(), at)
@Test
fun `a date moved after its defaults rang brings them`() {
val form = TaskForm(title = "t", listId = 1, start = now + 2.days, due = now + 5.days, reminders = listOf(hourBeforeDue))
assertThat(moved(form, start = now, due = now + 5.days)).containsExactly(hourBeforeDue, dayBeforeStart)
// Its date still ahead, but the lead already past.
assertThat(moved(form, start = now + 2.hours, due = now + 5.days)).containsExactly(hourBeforeDue, dayBeforeStart)
}
@Test
fun `a moved due brings its defaults the same way`() {
val ownStart = TaskReminder(0, fromStart = true)
val form = TaskForm(title = "t", listId = 1, start = now - 3.days, due = now + 5.days, reminders = listOf(ownStart))
assertThat(moved(form, start = now - 3.days, due = now)).containsExactly(ownStart, dayBeforeDue, hourBeforeDue)
}
@Test
fun `a moved date keeps the form when its defaults had not rung or are named`() {
val form = TaskForm(title = "t", listId = 1, start = now + 3.days, due = now + 5.days, reminders = listOf(hourBeforeDue))
assertThat(moved(form, start = now + 2.days, due = now + 5.days)).containsExactly(hourBeforeDue)
val ownStart = TaskReminder(30, fromStart = true)
assertThat(moved(form.copy(reminders = listOf(ownStart)), start = now, due = now + 5.days)).containsExactly(ownStart)
}
@Test
fun `a task without reminders of its own keeps none when a past date moves`() {
val form = TaskForm(title = "t", listId = 1, start = now + 2.days, due = now + 5.days)
assertThat(moved(form, start = now, due = now + 5.days)).isEmpty()
assertThat(moved(form.copy(rrule = "FREQ=DAILY", reminders = listOf(hourBeforeDue)), start = now, due = now + 5.days))
.containsExactly(hourBeforeDue)
}
@Test
fun `turning all-day without moving the date is not a move`() {
val form = TaskForm(
title = "t",
listId = 1,
start = allDayInstantOf(LocalDate.of(2026, 10, 1)),
due = allDayInstantOf(LocalDate.of(2026, 10, 6)),
isAllDay = true,
reminders = listOf(TaskReminder(1_440)),
)
assertThat(moved(form, start = now, due = now + 5.days, wasAllDay = false)).containsExactly(TaskReminder(1_440))
}
@Test
fun `an all-day date is past by its own calendar, not by UTC midnight`() {
val ownStart = TaskReminder(0, fromStart = true)
val newYork = ZoneId.of("America/New_York")
// 20:00 on Sep 30 in New York: Oct 1 has not begun there.
val form = TaskForm(
title = "t",
listId = 1,
start = allDayInstantOf(LocalDate.of(2026, 9, 28)),
due = allDayInstantOf(LocalDate.of(2026, 10, 2)),
isAllDay = true,
reminders = listOf(ownStart),
)
val due = allDayInstantOf(LocalDate.of(2026, 10, 1))
assertThat(
StoredReminders.withDefaults(form, form.start, due, true, settings, Instant.parse("2026-10-01T00:00:00Z").toEpochMilliseconds(), newYork),
).containsExactly(ownStart)
}
@Test
fun `a gained date the form already counts from keeps the form`() {
val current = testTask(start = now + 2.days)