From 38a35be0f0003904ede947f9cf0dafa8fe56889d Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 13 Jul 2026 17:25:51 +0200 Subject: [PATCH] fix(reminders): show the day for reminders on another day (#46) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 9 ++ .../data/reminders/ReminderNotifier.kt | 17 ++- .../data/reminders/ReminderTimeText.kt | 84 +++++++++++- app/src/main/res/values/strings.xml | 3 + .../data/reminders/ReminderTimeTextTest.kt | 127 ++++++++++++++---- 5 files changed, 205 insertions(+), 35 deletions(-) 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") } -- 2.49.1