fix(reminders): show the day for reminders on another day (#46)
All checks were successful
CI / ci (pull_request) Successful in 5m31s
Translations / check (pull_request) Successful in 5s

A reminder fired ahead of an event on a different day showed only the
event's time (e.g. "09:30 – 10:00"), making it look like it was
happening today. reminderTimeText now prefixes timed events with a
relative day: "Tomorrow"/"Yesterday", the short weekday for another day
this week, or the exact date for anything further out.

The this-week boundary honours the user's "week starts on" setting: the
resolved first day of week is threaded through from ReminderNotifier, so
e.g. a Sunday reads as next week under a Sunday-start locale. All-day
events keep their explicit date (never ambiguous), and cross-midnight
timed events keep both explicit dates.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 17:25:51 +02:00
parent f9f0572ec5
commit 38a35be0f0
5 changed files with 205 additions and 35 deletions

View File

@@ -21,6 +21,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
the target and removes the original — the same approach other calendar apps the target and removes the original — the same approach other calendar apps
take. Thanks to @prismplex for the suggestion ([#39]). take. Thanks to @prismplex for the suggestion ([#39]).
### Fixed
- Reminders for events on another day no longer read as if they were today. A
reminder fired ahead of time — say, the day before — used to show only the
event's time, making it look like it was happening now. The notification now
says which day: **Tomorrow** or **Yesterday**, the weekday for another day this
week, or the date for anything further out. Thanks to @moonj for the report
([#46]).
## [2.14.0] — 2026-07-06 ## [2.14.0] — 2026-07-06
### Added ### Added
@@ -892,3 +900,4 @@ automatically, with zero telemetry and no internet permission.
[#37]: https://codeberg.org/jlmakiola/calendula/issues/37 [#37]: https://codeberg.org/jlmakiola/calendula/issues/37
[#39]: https://codeberg.org/jlmakiola/calendula/issues/39 [#39]: https://codeberg.org/jlmakiola/calendula/issues/39
[#40]: https://codeberg.org/jlmakiola/calendula/issues/40 [#40]: https://codeberg.org/jlmakiola/calendula/issues/40
[#46]: https://codeberg.org/jlmakiola/calendula/issues/46

View File

@@ -17,7 +17,11 @@ import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.is24Hour import de.jeanlucmakiola.calendula.data.prefs.is24Hour
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.datetime.isoDayNumber
import java.time.DayOfWeek
import java.time.Instant
import java.time.ZoneId import java.time.ZoneId
import java.util.Locale import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
@@ -55,13 +59,22 @@ class ReminderNotifier @Inject constructor(
val title = alert.title.ifBlank { context.getString(R.string.event_untitled) } val title = alert.title.ifBlank { context.getString(R.string.event_untitled) }
val is24Hour = settingsPrefs.timeFormat.first() val is24Hour = settingsPrefs.timeFormat.first()
.is24Hour(android.text.format.DateFormat.is24HourFormat(context)) .is24Hour(android.text.format.DateFormat.is24HourFormat(context))
val zone = ZoneId.systemDefault()
val locale = Locale.getDefault()
// resolveFirstDay yields a kotlinx.datetime day; bridge it to java.time by
// its shared ISO number (1..7) for the date math in reminderTimeText.
val firstDayOfWeek = DayOfWeek.of(settingsPrefs.weekStart.first().resolveFirstDay(locale).isoDayNumber)
val time = reminderTimeText( val time = reminderTimeText(
beginMillis = alert.beginMillis, beginMillis = alert.beginMillis,
endMillis = alert.endMillis, endMillis = alert.endMillis,
isAllDay = alert.isAllDay, isAllDay = alert.isAllDay,
zone = ZoneId.systemDefault(), zone = zone,
locale = Locale.getDefault(), locale = locale,
is24Hour = is24Hour, is24Hour = is24Hour,
today = Instant.now().atZone(zone).toLocalDate(),
firstDayOfWeek = firstDayOfWeek,
tomorrowLabel = context.getString(R.string.reminder_day_tomorrow),
yesterdayLabel = context.getString(R.string.reminder_day_yesterday),
) )
val text = listOfNotNull(time, alert.location).joinToString(" · ") val text = listOfNotNull(time, alert.location).joinToString(" · ")
val notification = NotificationCompat.Builder(context, CHANNEL_ID) val notification = NotificationCompat.Builder(context, CHANNEL_ID)

View File

@@ -1,23 +1,37 @@
package de.jeanlucmakiola.calendula.data.reminders package de.jeanlucmakiola.calendula.data.reminders
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
import java.time.DayOfWeek
import java.time.Instant import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId import java.time.ZoneId
import java.time.ZoneOffset import java.time.ZoneOffset
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle import java.time.format.FormatStyle
import java.time.format.TextStyle
import java.time.temporal.ChronoUnit
import java.util.Locale import java.util.Locale
/** /**
* The one line of time context in a reminder notification. Pure so it can be * The one line of time context in a reminder notification. Pure so it can be
* JVM-tested: * JVM-tested.
* *
* - timed, same day: "09:30 10:00" * Timed events that fall on a day other than [today] are prefixed with that
* - timed, crossing days: "11 Jun, 23:30 12 Jun, 00:30" (medium date + short time) * day, so a reminder fired ahead of time no longer reads as if the event were
* today (issue #46). The prefix prefers natural language and stays short:
*
* - today: "09:30 10:00" (no prefix)
* - tomorrow / yesterday: "Tomorrow, 09:30 10:00" ([tomorrowLabel] / [yesterdayLabel])
* - elsewhere this week: "Thu, 09:30 10:00" (localized short weekday)
* - further out: "16 Jul, 09:30 10:00" (medium date — a weekday
* alone would be ambiguous)
* - timed, crossing days: "11 Jun, 23:30 12 Jun, 00:30" (medium date + short time,
* already unambiguous)
* - all-day, one day: "11 Jun 2026" * - all-day, one day: "11 Jun 2026"
* - all-day, multi-day: "11 Jun 2026 12 Jun 2026" * - all-day, multi-day: "11 Jun 2026 12 Jun 2026"
* *
* All-day instances store UTC midnights with an exclusive end, so they are * All-day instances already carry an explicit date, so they never gain a
* relative prefix. They store UTC midnights with an exclusive end, so they are
* read in UTC and the end day is the last *covered* day. * read in UTC and the end day is the last *covered* day.
*/ */
fun reminderTimeText( fun reminderTimeText(
@@ -27,6 +41,10 @@ fun reminderTimeText(
zone: ZoneId, zone: ZoneId,
locale: Locale, locale: Locale,
is24Hour: Boolean, is24Hour: Boolean,
today: LocalDate,
firstDayOfWeek: DayOfWeek,
tomorrowLabel: String,
yesterdayLabel: String,
): String { ): String {
if (isAllDay) { if (isAllDay) {
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale) val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
@@ -43,18 +61,70 @@ fun reminderTimeText(
} }
val timeFormat = timeOfDayFormatter(is24Hour, locale) val timeFormat = timeOfDayFormatter(is24Hour, locale)
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
val begin = Instant.ofEpochMilli(beginMillis).atZone(zone) val begin = Instant.ofEpochMilli(beginMillis).atZone(zone)
val end = Instant.ofEpochMilli(endMillis).atZone(zone) val end = Instant.ofEpochMilli(endMillis).atZone(zone)
return if (begin.toLocalDate() == end.toLocalDate()) { return if (begin.toLocalDate() == end.toLocalDate()) {
timeFormat.format(begin) + RANGE + timeFormat.format(end) val range = timeFormat.format(begin) + RANGE + timeFormat.format(end)
val prefix = relativeDayPrefix(
day = begin.toLocalDate(),
today = today,
firstDayOfWeek = firstDayOfWeek,
locale = locale,
dateFormat = dateFormat,
tomorrowLabel = tomorrowLabel,
yesterdayLabel = yesterdayLabel,
)
if (prefix == null) range else "$prefix, $range"
} else { } else {
// Cross-day: medium date + the chosen short time, joined per side. Built // Cross-day: medium date + the chosen short time, joined per side. Built
// from the two formatters (not ofLocalizedDateTime) so the 12/24h choice // from the two formatters (not ofLocalizedDateTime) so the 12/24h choice
// applies to the time portion too. // applies to the time portion too. The explicit dates already say which
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale) // day, so no relative prefix is layered on top.
val dateTime = { z: java.time.ZonedDateTime -> "${dateFormat.format(z)}, ${timeFormat.format(z)}" } val dateTime = { z: java.time.ZonedDateTime -> "${dateFormat.format(z)}, ${timeFormat.format(z)}" }
dateTime(begin) + RANGE + dateTime(end) dateTime(begin) + RANGE + dateTime(end)
} }
} }
/**
* A short label for [day] relative to [today], or `null` when it *is* today (the
* common case, which needs no prefix). Weekday names are used only within the
* current week — a "next Wednesday" would be indistinguishable from this one, so
* anything past this week falls back to the exact date.
*/
private fun relativeDayPrefix(
day: LocalDate,
today: LocalDate,
firstDayOfWeek: DayOfWeek,
locale: Locale,
dateFormat: DateTimeFormatter,
tomorrowLabel: String,
yesterdayLabel: String,
): String? = when (ChronoUnit.DAYS.between(today, day)) {
0L -> null
1L -> tomorrowLabel
-1L -> yesterdayLabel
else -> if (isSameWeek(day, today, firstDayOfWeek)) {
day.dayOfWeek.getDisplayName(TextStyle.SHORT, locale)
} else {
dateFormat.format(day)
}
}
/**
* True when [day] and [today] share the same week. The week boundary honours the
* user's *week starts on* setting (already resolved to a concrete [firstDayOfWeek],
* with [firstDayOfWeek] falling back to the locale default upstream).
*/
private fun isSameWeek(day: LocalDate, today: LocalDate, firstDayOfWeek: DayOfWeek): Boolean {
val startOfWeek = today.previousOrSame(firstDayOfWeek)
return !day.isBefore(startOfWeek) && day.isBefore(startOfWeek.plusWeeks(1))
}
/** The most recent [target] on or before this date (this date itself when it matches). */
private fun LocalDate.previousOrSame(target: DayOfWeek): LocalDate {
val backtrack = (dayOfWeek.value - target.value + 7) % 7
return minusDays(backtrack.toLong())
}
private const val RANGE = " " private const val RANGE = " "

View File

@@ -226,6 +226,9 @@
<string name="reminder_onboarding_skip_button">Not now</string> <string name="reminder_onboarding_skip_button">Not now</string>
<string name="reminder_action_snooze">Snooze</string> <string name="reminder_action_snooze">Snooze</string>
<string name="reminder_action_dismiss">Dismiss</string> <string name="reminder_action_dismiss">Dismiss</string>
<!-- Day context prefix in a reminder for an event on another day (v2.15.0) -->
<string name="reminder_day_tomorrow">Tomorrow</string>
<string name="reminder_day_yesterday">Yesterday</string>
<!-- View switcher (M1) --> <!-- View switcher (M1) -->
<string name="view_month">Month</string> <string name="view_month">Month</string>

View File

@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.data.reminders
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import java.time.DayOfWeek
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneId import java.time.ZoneId
@@ -18,41 +19,110 @@ class ReminderTimeTextTest {
private fun utcMidnight(date: LocalDate): Long = private fun utcMidnight(date: LocalDate): Long =
date.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli() date.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
/** Wrapper defaulting `today` to the event's own begin day, so unrelated tests read "today". */
private fun text(
beginMillis: Long,
endMillis: Long,
isAllDay: Boolean = false,
zone: ZoneId = berlin,
locale: Locale = Locale.GERMANY,
is24Hour: Boolean = true,
today: LocalDate? = null,
firstDayOfWeek: DayOfWeek = DayOfWeek.MONDAY,
): String = reminderTimeText(
beginMillis = beginMillis,
endMillis = endMillis,
isAllDay = isAllDay,
zone = zone,
locale = locale,
is24Hour = is24Hour,
today = today ?: java.time.Instant.ofEpochMilli(beginMillis).atZone(zone).toLocalDate(),
firstDayOfWeek = firstDayOfWeek,
tomorrowLabel = "Tomorrow",
yesterdayLabel = "Yesterday",
)
@Test @Test
fun `timed event on one day shows just the time range`() { fun `timed event today shows just the time range`() {
val text = reminderTimeText( val text = text(
beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 9, 30), berlin), beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 9, 30), berlin),
endMillis = millisAt(LocalDateTime.of(2026, 6, 11, 10, 0), berlin), endMillis = millisAt(LocalDateTime.of(2026, 6, 11, 10, 0), berlin),
isAllDay = false,
zone = berlin,
locale = Locale.GERMANY,
is24Hour = true,
) )
assertThat(text).isEqualTo("09:30 10:00") assertThat(text).isEqualTo("09:30 10:00")
} }
@Test @Test
fun `12-hour preference renders an am-pm time range`() { fun `12-hour preference renders an am-pm time range`() {
val text = reminderTimeText( val text = text(
beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 14, 0), berlin), beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 14, 0), berlin),
endMillis = millisAt(LocalDateTime.of(2026, 6, 11, 15, 0), berlin), endMillis = millisAt(LocalDateTime.of(2026, 6, 11, 15, 0), berlin),
isAllDay = false,
zone = berlin,
locale = Locale.US, locale = Locale.US,
is24Hour = false, is24Hour = false,
) )
assertThat(text).isEqualTo("2:00 PM 3:00 PM") assertThat(text).isEqualTo("2:00 PM 3:00 PM")
} }
@Test
fun `tomorrow's event is prefixed with the tomorrow label`() {
val text = text(
beginMillis = millisAt(LocalDateTime.of(2026, 6, 12, 9, 30), berlin),
endMillis = millisAt(LocalDateTime.of(2026, 6, 12, 10, 0), berlin),
today = LocalDate.of(2026, 6, 11),
)
assertThat(text).isEqualTo("Tomorrow, 09:30 10:00")
}
@Test
fun `yesterday's event is prefixed with the yesterday label`() {
val text = text(
beginMillis = millisAt(LocalDateTime.of(2026, 6, 10, 9, 30), berlin),
endMillis = millisAt(LocalDateTime.of(2026, 6, 10, 10, 0), berlin),
today = LocalDate.of(2026, 6, 11),
)
assertThat(text).isEqualTo("Yesterday, 09:30 10:00")
}
@Test
fun `an event later this week is prefixed with the short weekday`() {
// 2026-06-11 is a Thursday; +3 days lands on Sunday, still this (Mon-based) week.
val text = text(
beginMillis = millisAt(LocalDateTime.of(2026, 6, 14, 9, 30), berlin),
endMillis = millisAt(LocalDateTime.of(2026, 6, 14, 10, 0), berlin),
today = LocalDate.of(2026, 6, 11),
)
assertThat(text).isEqualTo("So., 09:30 10:00")
}
@Test
fun `the week boundary honours the week-start setting`() {
// Today Thu 2026-06-11, event Sun 2026-06-14. With a Sunday-start week the
// Thursday's week runs Sun 06-07..Sat 06-13, so 06-14 is already next week
// and must render as a date — the opposite of the Monday-start case above.
val text = text(
beginMillis = millisAt(LocalDateTime.of(2026, 6, 14, 9, 30), berlin),
endMillis = millisAt(LocalDateTime.of(2026, 6, 14, 10, 0), berlin),
today = LocalDate.of(2026, 6, 11),
firstDayOfWeek = DayOfWeek.SUNDAY,
)
assertThat(text).isEqualTo("14.06.2026, 09:30 10:00")
}
@Test
fun `an event next week falls back to the exact date, not a weekday`() {
// 2026-06-15 is the following Monday — a weekday alone would be ambiguous.
val text = text(
beginMillis = millisAt(LocalDateTime.of(2026, 6, 15, 9, 30), berlin),
endMillis = millisAt(LocalDateTime.of(2026, 6, 15, 10, 0), berlin),
today = LocalDate.of(2026, 6, 11),
)
assertThat(text).isEqualTo("15.06.2026, 09:30 10:00")
}
@Test @Test
fun `timed event crossing midnight includes both dates`() { fun `timed event crossing midnight includes both dates`() {
val text = reminderTimeText( val text = text(
beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 23, 30), berlin), beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 23, 30), berlin),
endMillis = millisAt(LocalDateTime.of(2026, 6, 12, 0, 30), berlin), endMillis = millisAt(LocalDateTime.of(2026, 6, 12, 0, 30), berlin),
isAllDay = false,
zone = berlin,
locale = Locale.GERMANY,
is24Hour = true,
) )
assertThat(text).contains("11.06.2026") assertThat(text).contains("11.06.2026")
assertThat(text).contains("12.06.2026") assertThat(text).contains("12.06.2026")
@@ -61,28 +131,35 @@ class ReminderTimeTextTest {
@Test @Test
fun `all-day single day shows one date, read in UTC`() { fun `all-day single day shows one date, read in UTC`() {
val text = reminderTimeText( val text = text(
beginMillis = utcMidnight(LocalDate.of(2026, 6, 11)), beginMillis = utcMidnight(LocalDate.of(2026, 6, 11)),
endMillis = utcMidnight(LocalDate.of(2026, 6, 12)), endMillis = utcMidnight(LocalDate.of(2026, 6, 12)),
isAllDay = true, isAllDay = true,
// Zone must not matter for all-day events: UTC midnight is // Zone must not matter for all-day events: UTC midnight is
// 02:00 in Berlin — naive local reading would shift the day. // 02:00 in Berlin — naive local reading would shift the day.
zone = berlin, today = LocalDate.of(2026, 6, 11),
locale = Locale.GERMANY,
is24Hour = true,
) )
assertThat(text).isEqualTo("11.06.2026") assertThat(text).isEqualTo("11.06.2026")
} }
@Test
fun `all-day event tomorrow still shows the exact date, no relative prefix`() {
val text = text(
beginMillis = utcMidnight(LocalDate.of(2026, 6, 12)),
endMillis = utcMidnight(LocalDate.of(2026, 6, 13)),
isAllDay = true,
today = LocalDate.of(2026, 6, 11),
)
assertThat(text).isEqualTo("12.06.2026")
}
@Test @Test
fun `all-day multi-day shows the last covered day, not the exclusive end`() { fun `all-day multi-day shows the last covered day, not the exclusive end`() {
val text = reminderTimeText( val text = text(
beginMillis = utcMidnight(LocalDate.of(2026, 6, 11)), beginMillis = utcMidnight(LocalDate.of(2026, 6, 11)),
endMillis = utcMidnight(LocalDate.of(2026, 6, 13)), endMillis = utcMidnight(LocalDate.of(2026, 6, 13)),
isAllDay = true, isAllDay = true,
zone = berlin, today = LocalDate.of(2026, 6, 11),
locale = Locale.GERMANY,
is24Hour = true,
) )
assertThat(text).isEqualTo("11.06.2026 12.06.2026") assertThat(text).isEqualTo("11.06.2026 12.06.2026")
} }
@@ -90,13 +167,11 @@ class ReminderTimeTextTest {
@Test @Test
fun `degenerate all-day range never renders an inverted span`() { fun `degenerate all-day range never renders an inverted span`() {
val day = utcMidnight(LocalDate.of(2026, 6, 11)) val day = utcMidnight(LocalDate.of(2026, 6, 11))
val text = reminderTimeText( val text = text(
beginMillis = day, beginMillis = day,
endMillis = day, endMillis = day,
isAllDay = true, isAllDay = true,
zone = berlin, today = LocalDate.of(2026, 6, 11),
locale = Locale.GERMANY,
is24Hour = true,
) )
assertThat(text).isEqualTo("11.06.2026") assertThat(text).isEqualTo("11.06.2026")
} }