diff --git a/CHANGELOG.md b/CHANGELOG.md
index 83143b7..1069d2b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
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
### Added
@@ -892,3 +900,4 @@ automatically, with zero telemetry and no internet permission.
[#37]: https://codeberg.org/jlmakiola/calendula/issues/37
[#39]: https://codeberg.org/jlmakiola/calendula/issues/39
[#40]: https://codeberg.org/jlmakiola/calendula/issues/40
+[#46]: https://codeberg.org/jlmakiola/calendula/issues/46
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderNotifier.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderNotifier.kt
index 9bd0dde..73a4656 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderNotifier.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderNotifier.kt
@@ -17,7 +17,11 @@ import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
+import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import kotlinx.coroutines.flow.first
+import kotlinx.datetime.isoDayNumber
+import java.time.DayOfWeek
+import java.time.Instant
import java.time.ZoneId
import java.util.Locale
import javax.inject.Inject
@@ -55,13 +59,22 @@ class ReminderNotifier @Inject constructor(
val title = alert.title.ifBlank { context.getString(R.string.event_untitled) }
val is24Hour = settingsPrefs.timeFormat.first()
.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(
beginMillis = alert.beginMillis,
endMillis = alert.endMillis,
isAllDay = alert.isAllDay,
- zone = ZoneId.systemDefault(),
- locale = Locale.getDefault(),
+ zone = zone,
+ locale = locale,
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 notification = NotificationCompat.Builder(context, CHANNEL_ID)
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeText.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeText.kt
index ce7fd21..cdc7462 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeText.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeText.kt
@@ -1,23 +1,37 @@
package de.jeanlucmakiola.calendula.data.reminders
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
+import java.time.DayOfWeek
import java.time.Instant
+import java.time.LocalDate
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
+import java.time.format.TextStyle
+import java.time.temporal.ChronoUnit
import java.util.Locale
/**
* 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, crossing days: "11 Jun, 23:30 – 12 Jun, 00:30" (medium date + short time)
+ * Timed events that fall on a day other than [today] are prefixed with that
+ * 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, 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.
*/
fun reminderTimeText(
@@ -27,6 +41,10 @@ fun reminderTimeText(
zone: ZoneId,
locale: Locale,
is24Hour: Boolean,
+ today: LocalDate,
+ firstDayOfWeek: DayOfWeek,
+ tomorrowLabel: String,
+ yesterdayLabel: String,
): String {
if (isAllDay) {
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
@@ -43,18 +61,70 @@ fun reminderTimeText(
}
val timeFormat = timeOfDayFormatter(is24Hour, locale)
+ val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
val begin = Instant.ofEpochMilli(beginMillis).atZone(zone)
val end = Instant.ofEpochMilli(endMillis).atZone(zone)
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 {
// Cross-day: medium date + the chosen short time, joined per side. Built
// from the two formatters (not ofLocalizedDateTime) so the 12/24h choice
- // applies to the time portion too.
- val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
+ // applies to the time portion too. The explicit dates already say which
+ // day, so no relative prefix is layered on top.
val dateTime = { z: java.time.ZonedDateTime -> "${dateFormat.format(z)}, ${timeFormat.format(z)}" }
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 = " – "
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 87dd845..9966ef9 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -226,6 +226,9 @@
Not now
Snooze
Dismiss
+
+ Tomorrow
+ Yesterday
Month
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeTextTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeTextTest.kt
index 4d3f6c6..c8014a1 100644
--- a/app/src/test/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeTextTest.kt
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeTextTest.kt
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.data.reminders
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
+import java.time.DayOfWeek
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.ZoneId
@@ -18,41 +19,110 @@ class ReminderTimeTextTest {
private fun utcMidnight(date: LocalDate): Long =
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
- fun `timed event on one day shows just the time range`() {
- val text = reminderTimeText(
+ fun `timed event today shows just the time range`() {
+ val text = text(
beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 9, 30), 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")
}
@Test
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),
endMillis = millisAt(LocalDateTime.of(2026, 6, 11, 15, 0), berlin),
- isAllDay = false,
- zone = berlin,
locale = Locale.US,
is24Hour = false,
)
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
fun `timed event crossing midnight includes both dates`() {
- val text = reminderTimeText(
+ val text = text(
beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 23, 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("12.06.2026")
@@ -61,28 +131,35 @@ class ReminderTimeTextTest {
@Test
fun `all-day single day shows one date, read in UTC`() {
- val text = reminderTimeText(
+ val text = text(
beginMillis = utcMidnight(LocalDate.of(2026, 6, 11)),
endMillis = utcMidnight(LocalDate.of(2026, 6, 12)),
isAllDay = true,
// Zone must not matter for all-day events: UTC midnight is
// 02:00 in Berlin — naive local reading would shift the day.
- zone = berlin,
- locale = Locale.GERMANY,
- is24Hour = true,
+ today = LocalDate.of(2026, 6, 11),
)
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
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)),
endMillis = utcMidnight(LocalDate.of(2026, 6, 13)),
isAllDay = true,
- zone = berlin,
- locale = Locale.GERMANY,
- is24Hour = true,
+ today = LocalDate.of(2026, 6, 11),
)
assertThat(text).isEqualTo("11.06.2026 – 12.06.2026")
}
@@ -90,13 +167,11 @@ class ReminderTimeTextTest {
@Test
fun `degenerate all-day range never renders an inverted span`() {
val day = utcMidnight(LocalDate.of(2026, 6, 11))
- val text = reminderTimeText(
+ val text = text(
beginMillis = day,
endMillis = day,
isAllDay = true,
- zone = berlin,
- locale = Locale.GERMANY,
- is24Hour = true,
+ today = LocalDate.of(2026, 6, 11),
)
assertThat(text).isEqualTo("11.06.2026")
}