From 766c2ffcf8ad3c11fd3ffe1fac1395dcb7c902bb Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 27 Jun 2026 14:25:59 +0200 Subject: [PATCH] feat(agenda): add calendar-aligned "this week"/"this month" ranges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../calendula/ui/agenda/AgendaRange.kt | 45 ++++++++++++++--- .../calendula/ui/agenda/AgendaViewModel.kt | 15 ++++-- .../calendula/ui/common/Picker.kt | 10 +++- .../calendula/widget/WidgetData.kt | 6 ++- app/src/main/res/values-de/strings.xml | 8 +-- app/src/main/res/values/strings.xml | 8 +-- .../calendula/ui/agenda/AgendaRangeTest.kt | 49 ++++++++++++++++--- 7 files changed, 114 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRange.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRange.kt index 72106c4..e1f3bee 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRange.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRange.kt @@ -1,15 +1,26 @@ 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 - * 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 - * never degenerates near a week/month boundary. + * How far ahead the agenda (screen or widget) shows events, starting at today. + * + * Two flavours: + * - **Rolling** windows of a fixed length: [Day] (today), [Week] (7 days), + * [Month] (30 days), [Custom] (1–365 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 { data object Day : AgendaRange data object Week : AgendaRange data object Month : AgendaRange + data object ThisWeek : AgendaRange + data object ThisMonth : AgendaRange data class Custom(val days: Int) : AgendaRange companion object { @@ -19,11 +30,27 @@ sealed interface AgendaRange { } } -/** Inclusive number of days the window spans, starting at (and including) today. */ -fun AgendaRange.dayCount(): Int = when (this) { +private const val DAYS_PER_WEEK = 7 + +/** + * 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.Week -> 7 + AgendaRange.Week -> DAYS_PER_WEEK 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) } @@ -32,6 +59,8 @@ fun AgendaRange.storageValue(): String = when (this) { AgendaRange.Day -> "DAY" AgendaRange.Week -> "WEEK" AgendaRange.Month -> "MONTH" + AgendaRange.ThisWeek -> "THIS_WEEK" + AgendaRange.ThisMonth -> "THIS_MONTH" is AgendaRange.Custom -> "$CUSTOM_PREFIX$days" } @@ -40,6 +69,8 @@ fun parseAgendaRange(stored: String?, default: AgendaRange): AgendaRange = when stored == "DAY" -> AgendaRange.Day stored == "WEEK" -> AgendaRange.Week stored == "MONTH" -> AgendaRange.Month + stored == "THIS_WEEK" -> AgendaRange.ThisWeek + stored == "THIS_MONTH" -> AgendaRange.ThisMonth stored != null && stored.startsWith(CUSTOM_PREFIX) -> { val days = stored.removePrefix(CUSTOM_PREFIX).toIntOrNull() if (days != null) { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt index 65f2f1a..85a74c2 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt @@ -6,6 +6,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.di.IoDispatcher 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.EventInstance import de.jeanlucmakiola.calendula.domain.FailureReason @@ -18,10 +19,12 @@ import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone +import java.util.Locale import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.atTime import kotlinx.datetime.plus @@ -41,6 +44,10 @@ class AgendaViewModel @Inject constructor( 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 todayDate: LocalDate @@ -50,9 +57,11 @@ class AgendaViewModel @Inject constructor( val anchor: StateFlow = _anchor val state: StateFlow = - combine(_anchor, screenRange) { anchor, range -> anchor to range } - .flatMapLatest { (anchor, range) -> - val window = agendaRange(anchor, range.dayCount() - 1, zone) + combine(_anchor, screenRange, weekStartDay) { anchor, range, weekStart -> + Triple(anchor, range, weekStart) + } + .flatMapLatest { (anchor, range, weekStart) -> + val window = agendaRange(anchor, range.dayCount(anchor, weekStart) - 1, zone) combine( repository.calendars(), repository.instances(window), diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt index afbeeed..4c92b41 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt @@ -283,7 +283,13 @@ fun AgendaRangePicker( onSelect: (AgendaRange) -> 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 rowCount = presets.size + 1 // + the custom row @@ -390,6 +396,8 @@ private fun CustomDaysEditor( @Composable fun agendaRangeLabel(range: AgendaRange): String = when (range) { 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.Month -> stringResource(R.string.agenda_range_month) is AgendaRange.Custom -> pluralStringResource(R.plurals.agenda_range_days, range.days, range.days) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt index 5cea8c8..fd69f78 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt @@ -90,8 +90,10 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData { val zone = systemZone() val anchor = today(zone) val ep = widgetEntryPoint() - val range = ep.settingsPrefs().agendaWidgetRange.first() - val window = agendaRange(anchor, range.dayCount() - 1, zone) + val prefs = ep.settingsPrefs() + 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() return AgendaWidgetData.Ready(today = anchor, days = groupAgendaDays(anchor, instances, zone)) } diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 36c1f53..70eda74 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -280,9 +280,11 @@ Sonntag Agenda-Zeitraum Agenda-Widget-Zeitraum - 1 Tag - 1 Woche - 1 Monat + Heute + Diese Woche + Dieser Monat + Nächste 7 Tage + Nächste 30 Tage Benutzerdefiniert… Tage diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c0a7b8f..ed53195 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -273,9 +273,11 @@ Sunday Agenda range Agenda widget range - 1 day - 1 week - 1 month + Today + This week + This month + Next 7 days + Next 30 days Custom… Days diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRangeTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRangeTest.kt index 57d3db7..be030b1 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRangeTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRangeTest.kt @@ -1,27 +1,60 @@ package de.jeanlucmakiola.calendula.ui.agenda import com.google.common.truth.Truth.assertThat +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate import org.junit.jupiter.api.Test class AgendaRangeTest { + // A reference anchor: 2026-06-17 is a Wednesday. + private val wednesday = LocalDate(2026, 6, 17) + @Test - fun `fixed ranges have 1, 7, 30 day counts`() { - assertThat(AgendaRange.Day.dayCount()).isEqualTo(1) - assertThat(AgendaRange.Week.dayCount()).isEqualTo(7) - assertThat(AgendaRange.Month.dayCount()).isEqualTo(30) + fun `rolling ranges have fixed 1, 7, 30 day counts regardless of anchor`() { + assertThat(AgendaRange.Day.dayCount(wednesday, DayOfWeek.MONDAY)).isEqualTo(1) + assertThat(AgendaRange.Week.dayCount(wednesday, DayOfWeek.MONDAY)).isEqualTo(7) + 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 fun `custom day count is clamped to bounds`() { - assertThat(AgendaRange.Custom(45).dayCount()).isEqualTo(45) - assertThat(AgendaRange.Custom(0).dayCount()).isEqualTo(AgendaRange.MIN_CUSTOM_DAYS) - assertThat(AgendaRange.Custom(9_999).dayCount()).isEqualTo(AgendaRange.MAX_CUSTOM_DAYS) + assertThat(AgendaRange.Custom(45).dayCount(wednesday, DayOfWeek.MONDAY)).isEqualTo(45) + assertThat(AgendaRange.Custom(0).dayCount(wednesday, DayOfWeek.MONDAY)) + .isEqualTo(AgendaRange.MIN_CUSTOM_DAYS) + assertThat(AgendaRange.Custom(9_999).dayCount(wednesday, DayOfWeek.MONDAY)) + .isEqualTo(AgendaRange.MAX_CUSTOM_DAYS) } @Test 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)) .isEqualTo(range) }