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
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] (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 {
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) {

View File

@@ -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<LocalDate> = _anchor
val state: StateFlow<AgendaUiState> =
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),

View File

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

View File

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