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

@@ -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)

View File

@@ -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 = " "

View File

@@ -226,6 +226,9 @@
<string name="reminder_onboarding_skip_button">Not now</string>
<string name="reminder_action_snooze">Snooze</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) -->
<string name="view_month">Month</string>