feat(agenda): add calendar-aligned "this week"/"this month" ranges
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:
@@ -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] (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 {
|
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) {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user