feat(agenda): add calendar-aligned "this week"/"this month" ranges
All checks were successful
Translations / check (pull_request) Successful in 4s
CI / ci (pull_request) Successful in 11m22s

Extend AgendaRange with calendar-aligned windows alongside the rolling
ones: ThisWeek runs through the end of the current week (respecting the
week-start preference — a Monday start means everything before next
Monday), ThisMonth through the last day of the current month. dayCount now
takes the anchor day and week-start; the agenda screen and widget resolve
the week-start preference and pass it through. Rolling options relabelled
("Today", "Next 7 days", "Next 30 days") to read distinctly from the new
calendar-aligned ones.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-27 14:25:59 +02:00
parent add88fbadf
commit 766c2ffcf8
7 changed files with 114 additions and 27 deletions

View File

@@ -1,15 +1,26 @@
package de.jeanlucmakiola.calendula.ui.agenda package de.jeanlucmakiola.calendula.ui.agenda
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import java.time.YearMonth
/** /**
* How far ahead the agenda (screen or widget) shows events, as a rolling window * How far ahead the agenda (screen or widget) shows events, starting at today.
* starting at today. [Day]/[Week]/[Month] are fixed 1/7/30-day windows; [Custom] *
* is an arbitrary day count. Rolling (not calendar-aligned) windows so the span * Two flavours:
* never degenerates near a week/month boundary. * - **Rolling** windows of a fixed length: [Day] (today), [Week] (7 days),
* [Month] (30 days), [Custom] (1365 days). These never degenerate.
* - **Calendar-aligned** windows that end at a period boundary: [ThisWeek] runs
* through the end of the current week (respecting the week-start preference —
* a Monday start means "everything before next Monday"); [ThisMonth] runs
* through the last day of the current month. These shrink as the period ends.
*/ */
sealed interface AgendaRange { sealed interface AgendaRange {
data object Day : AgendaRange data object Day : AgendaRange
data object Week : AgendaRange data object Week : AgendaRange
data object Month : AgendaRange data object Month : AgendaRange
data object ThisWeek : AgendaRange
data object ThisMonth : AgendaRange
data class Custom(val days: Int) : AgendaRange data class Custom(val days: Int) : AgendaRange
companion object { companion object {
@@ -19,11 +30,27 @@ sealed interface AgendaRange {
} }
} }
/** Inclusive number of days the window spans, starting at (and including) today. */ private const val DAYS_PER_WEEK = 7
fun AgendaRange.dayCount(): Int = when (this) {
/**
* Inclusive number of days the window spans, starting at (and including)
* [anchor]. The calendar-aligned ranges depend on the anchor day and, for
* [AgendaRange.ThisWeek], the [weekStart] preference.
*/
fun AgendaRange.dayCount(anchor: LocalDate, weekStart: DayOfWeek): Int = when (this) {
AgendaRange.Day -> 1 AgendaRange.Day -> 1
AgendaRange.Week -> 7 AgendaRange.Week -> DAYS_PER_WEEK
AgendaRange.Month -> 30 AgendaRange.Month -> 30
AgendaRange.ThisWeek -> {
// Days elapsed since the week's first day (0 when today *is* the start),
// so the window is the rest of the week through the day before it repeats.
val sinceWeekStart = ((anchor.dayOfWeek.ordinal - weekStart.ordinal) + DAYS_PER_WEEK) % DAYS_PER_WEEK
DAYS_PER_WEEK - sinceWeekStart
}
AgendaRange.ThisMonth -> {
val daysInMonth = YearMonth.of(anchor.year, anchor.month.ordinal + 1).lengthOfMonth()
daysInMonth - anchor.day + 1
}
is AgendaRange.Custom -> days.coerceIn(AgendaRange.MIN_CUSTOM_DAYS, AgendaRange.MAX_CUSTOM_DAYS) is AgendaRange.Custom -> days.coerceIn(AgendaRange.MIN_CUSTOM_DAYS, AgendaRange.MAX_CUSTOM_DAYS)
} }
@@ -32,6 +59,8 @@ fun AgendaRange.storageValue(): String = when (this) {
AgendaRange.Day -> "DAY" AgendaRange.Day -> "DAY"
AgendaRange.Week -> "WEEK" AgendaRange.Week -> "WEEK"
AgendaRange.Month -> "MONTH" AgendaRange.Month -> "MONTH"
AgendaRange.ThisWeek -> "THIS_WEEK"
AgendaRange.ThisMonth -> "THIS_MONTH"
is AgendaRange.Custom -> "$CUSTOM_PREFIX$days" is AgendaRange.Custom -> "$CUSTOM_PREFIX$days"
} }
@@ -40,6 +69,8 @@ fun parseAgendaRange(stored: String?, default: AgendaRange): AgendaRange = when
stored == "DAY" -> AgendaRange.Day stored == "DAY" -> AgendaRange.Day
stored == "WEEK" -> AgendaRange.Week stored == "WEEK" -> AgendaRange.Week
stored == "MONTH" -> AgendaRange.Month stored == "MONTH" -> AgendaRange.Month
stored == "THIS_WEEK" -> AgendaRange.ThisWeek
stored == "THIS_MONTH" -> AgendaRange.ThisMonth
stored != null && stored.startsWith(CUSTOM_PREFIX) -> { stored != null && stored.startsWith(CUSTOM_PREFIX) -> {
val days = stored.removePrefix(CUSTOM_PREFIX).toIntOrNull() val days = stored.removePrefix(CUSTOM_PREFIX).toIntOrNull()
if (days != null) { if (days != null) {

View File

@@ -6,6 +6,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.IoDispatcher import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.FailureReason
@@ -18,10 +19,12 @@ import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import java.util.Locale
import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.atTime import kotlinx.datetime.atTime
import kotlinx.datetime.plus import kotlinx.datetime.plus
@@ -41,6 +44,10 @@ class AgendaViewModel @Inject constructor(
private val screenRange = settingsPrefs.agendaScreenRange private val screenRange = settingsPrefs.agendaScreenRange
// First day of the week, for the calendar-aligned "this week" range.
private val weekStartDay = settingsPrefs.weekStart
.map { it.resolveFirstDay(Locale.getDefault()) }
private val zone = TimeZone.currentSystemDefault() private val zone = TimeZone.currentSystemDefault()
private val todayDate: LocalDate private val todayDate: LocalDate
@@ -50,9 +57,11 @@ class AgendaViewModel @Inject constructor(
val anchor: StateFlow<LocalDate> = _anchor val anchor: StateFlow<LocalDate> = _anchor
val state: StateFlow<AgendaUiState> = val state: StateFlow<AgendaUiState> =
combine(_anchor, screenRange) { anchor, range -> anchor to range } combine(_anchor, screenRange, weekStartDay) { anchor, range, weekStart ->
.flatMapLatest { (anchor, range) -> Triple(anchor, range, weekStart)
val window = agendaRange(anchor, range.dayCount() - 1, zone) }
.flatMapLatest { (anchor, range, weekStart) ->
val window = agendaRange(anchor, range.dayCount(anchor, weekStart) - 1, zone)
combine( combine(
repository.calendars(), repository.calendars(),
repository.instances(window), repository.instances(window),

View File

@@ -283,7 +283,13 @@ fun AgendaRangePicker(
onSelect: (AgendaRange) -> Unit, onSelect: (AgendaRange) -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
) { ) {
val presets = listOf(AgendaRange.Day, AgendaRange.Week, AgendaRange.Month) val presets = listOf(
AgendaRange.Day,
AgendaRange.ThisWeek,
AgendaRange.ThisMonth,
AgendaRange.Week,
AgendaRange.Month,
)
val customSelected = selected is AgendaRange.Custom val customSelected = selected is AgendaRange.Custom
val rowCount = presets.size + 1 // + the custom row val rowCount = presets.size + 1 // + the custom row
@@ -390,6 +396,8 @@ private fun CustomDaysEditor(
@Composable @Composable
fun agendaRangeLabel(range: AgendaRange): String = when (range) { fun agendaRangeLabel(range: AgendaRange): String = when (range) {
AgendaRange.Day -> stringResource(R.string.agenda_range_day) AgendaRange.Day -> stringResource(R.string.agenda_range_day)
AgendaRange.ThisWeek -> stringResource(R.string.agenda_range_this_week)
AgendaRange.ThisMonth -> stringResource(R.string.agenda_range_this_month)
AgendaRange.Week -> stringResource(R.string.agenda_range_week) AgendaRange.Week -> stringResource(R.string.agenda_range_week)
AgendaRange.Month -> stringResource(R.string.agenda_range_month) AgendaRange.Month -> stringResource(R.string.agenda_range_month)
is AgendaRange.Custom -> pluralStringResource(R.plurals.agenda_range_days, range.days, range.days) is AgendaRange.Custom -> pluralStringResource(R.plurals.agenda_range_days, range.days, range.days)

View File

@@ -90,8 +90,10 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
val zone = systemZone() val zone = systemZone()
val anchor = today(zone) val anchor = today(zone)
val ep = widgetEntryPoint() val ep = widgetEntryPoint()
val range = ep.settingsPrefs().agendaWidgetRange.first() val prefs = ep.settingsPrefs()
val window = agendaRange(anchor, range.dayCount() - 1, zone) val range = prefs.agendaWidgetRange.first()
val weekStart = prefs.weekStart.first().resolveFirstDay(Locale.getDefault())
val window = agendaRange(anchor, range.dayCount(anchor, weekStart) - 1, zone)
val instances = ep.calendarRepository().instances(window).first() val instances = ep.calendarRepository().instances(window).first()
return AgendaWidgetData.Ready(today = anchor, days = groupAgendaDays(anchor, instances, zone)) return AgendaWidgetData.Ready(today = anchor, days = groupAgendaDays(anchor, instances, zone))
} }

View File

@@ -280,9 +280,11 @@
<string name="settings_week_start_sunday">Sonntag</string> <string name="settings_week_start_sunday">Sonntag</string>
<string name="settings_agenda_range">Agenda-Zeitraum</string> <string name="settings_agenda_range">Agenda-Zeitraum</string>
<string name="settings_agenda_widget_range">Agenda-Widget-Zeitraum</string> <string name="settings_agenda_widget_range">Agenda-Widget-Zeitraum</string>
<string name="agenda_range_day">1 Tag</string> <string name="agenda_range_day">Heute</string>
<string name="agenda_range_week">1 Woche</string> <string name="agenda_range_this_week">Diese Woche</string>
<string name="agenda_range_month">1 Monat</string> <string name="agenda_range_this_month">Dieser Monat</string>
<string name="agenda_range_week">Nächste 7 Tage</string>
<string name="agenda_range_month">Nächste 30 Tage</string>
<string name="agenda_range_custom">Benutzerdefiniert…</string> <string name="agenda_range_custom">Benutzerdefiniert…</string>
<string name="agenda_range_custom_hint">Tage</string> <string name="agenda_range_custom_hint">Tage</string>
<plurals name="agenda_range_days"> <plurals name="agenda_range_days">

View File

@@ -273,9 +273,11 @@
<string name="settings_week_start_sunday">Sunday</string> <string name="settings_week_start_sunday">Sunday</string>
<string name="settings_agenda_range">Agenda range</string> <string name="settings_agenda_range">Agenda range</string>
<string name="settings_agenda_widget_range">Agenda widget range</string> <string name="settings_agenda_widget_range">Agenda widget range</string>
<string name="agenda_range_day">1 day</string> <string name="agenda_range_day">Today</string>
<string name="agenda_range_week">1 week</string> <string name="agenda_range_this_week">This week</string>
<string name="agenda_range_month">1 month</string> <string name="agenda_range_this_month">This month</string>
<string name="agenda_range_week">Next 7 days</string>
<string name="agenda_range_month">Next 30 days</string>
<string name="agenda_range_custom">Custom…</string> <string name="agenda_range_custom">Custom…</string>
<string name="agenda_range_custom_hint">Days</string> <string name="agenda_range_custom_hint">Days</string>
<plurals name="agenda_range_days"> <plurals name="agenda_range_days">

View File

@@ -1,27 +1,60 @@
package de.jeanlucmakiola.calendula.ui.agenda package de.jeanlucmakiola.calendula.ui.agenda
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
class AgendaRangeTest { class AgendaRangeTest {
// A reference anchor: 2026-06-17 is a Wednesday.
private val wednesday = LocalDate(2026, 6, 17)
@Test @Test
fun `fixed ranges have 1, 7, 30 day counts`() { fun `rolling ranges have fixed 1, 7, 30 day counts regardless of anchor`() {
assertThat(AgendaRange.Day.dayCount()).isEqualTo(1) assertThat(AgendaRange.Day.dayCount(wednesday, DayOfWeek.MONDAY)).isEqualTo(1)
assertThat(AgendaRange.Week.dayCount()).isEqualTo(7) assertThat(AgendaRange.Week.dayCount(wednesday, DayOfWeek.MONDAY)).isEqualTo(7)
assertThat(AgendaRange.Month.dayCount()).isEqualTo(30) assertThat(AgendaRange.Month.dayCount(wednesday, DayOfWeek.MONDAY)).isEqualTo(30)
}
@Test
fun `this week runs through the day before the week repeats`() {
// Mon-start week: Wed → Wed,Thu,Fri,Sat,Sun = 5 days (everything before next Monday).
assertThat(AgendaRange.ThisWeek.dayCount(wednesday, DayOfWeek.MONDAY)).isEqualTo(5)
// On the first day of the week the whole 7 days remain.
val monday = LocalDate(2026, 6, 15)
assertThat(AgendaRange.ThisWeek.dayCount(monday, DayOfWeek.MONDAY)).isEqualTo(7)
// A Sunday-start week shifts the boundary: Wed → 4 days left (through Saturday).
assertThat(AgendaRange.ThisWeek.dayCount(wednesday, DayOfWeek.SUNDAY)).isEqualTo(4)
}
@Test
fun `this month runs through the last day of the month`() {
// June has 30 days: the 17th leaves 14 days (17..30 inclusive).
assertThat(AgendaRange.ThisMonth.dayCount(wednesday, DayOfWeek.MONDAY)).isEqualTo(14)
// February 2026 (non-leap) has 28 days.
assertThat(AgendaRange.ThisMonth.dayCount(LocalDate(2026, 2, 20), DayOfWeek.MONDAY))
.isEqualTo(9)
} }
@Test @Test
fun `custom day count is clamped to bounds`() { fun `custom day count is clamped to bounds`() {
assertThat(AgendaRange.Custom(45).dayCount()).isEqualTo(45) assertThat(AgendaRange.Custom(45).dayCount(wednesday, DayOfWeek.MONDAY)).isEqualTo(45)
assertThat(AgendaRange.Custom(0).dayCount()).isEqualTo(AgendaRange.MIN_CUSTOM_DAYS) assertThat(AgendaRange.Custom(0).dayCount(wednesday, DayOfWeek.MONDAY))
assertThat(AgendaRange.Custom(9_999).dayCount()).isEqualTo(AgendaRange.MAX_CUSTOM_DAYS) .isEqualTo(AgendaRange.MIN_CUSTOM_DAYS)
assertThat(AgendaRange.Custom(9_999).dayCount(wednesday, DayOfWeek.MONDAY))
.isEqualTo(AgendaRange.MAX_CUSTOM_DAYS)
} }
@Test @Test
fun `fixed ranges round-trip through storage`() { fun `fixed ranges round-trip through storage`() {
listOf(AgendaRange.Day, AgendaRange.Week, AgendaRange.Month).forEach { range -> listOf(
AgendaRange.Day,
AgendaRange.Week,
AgendaRange.Month,
AgendaRange.ThisWeek,
AgendaRange.ThisMonth,
).forEach { range ->
assertThat(parseAgendaRange(range.storageValue(), default = AgendaRange.Day)) assertThat(parseAgendaRange(range.storageValue(), default = AgendaRange.Day))
.isEqualTo(range) .isEqualTo(range)
} }