From ee9d65068dff77e319d7b34170806ddfbe7bb5e3 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 20 Jul 2026 17:59:53 +0200 Subject: [PATCH] fix(month): don't drag the continuous window back to 1900 (#38) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The continuous grid failed outright with "Could not read the calendar" the moment it opened. Before the first layout pass LazyListState reports no visible items, and the screen defaulted that to item 0 — claiming January 1900 was on screen. The load window widened around it, its pad ran past the epoch to index -4, and yearMonthForIndex indexed Month.entries out of bounds; the flow's catch turned the crash into the generic provider failure. Week indexing never showed this: index 0 was a real week, so a bogus report only cost one wasted query. - The visible-range report is skipped entirely while the list has no items, rather than standing in a default for them. - clampMonthWindow holds any window inside 1900–2100, so a pad at either end can't produce an index with no month behind it. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/month/MonthScreen.kt | 13 +++++++++++-- .../calendula/ui/month/MonthViewModel.kt | 13 ++++++++++++- .../ui/month/ContinuousMonthIndexTest.kt | 19 +++++++++++++++++++ 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index 40474b5..a45ab09 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -101,6 +101,7 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.floret.time.isoWeekNumber import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.launch import kotlinx.datetime.DayOfWeek import kotlinx.datetime.LocalDate @@ -176,10 +177,18 @@ fun MonthScreen( LaunchedEffect(listState, continuous) { if (!continuous) return@LaunchedEffect snapshotFlow { + // Empty before the first layout pass — and reporting a default of 0 + // for it would claim January 1900 is on screen and drag the whole + // window back there. val visible = listState.layoutInfo.visibleItemsInfo - monthIndexForItem(visible.firstOrNull()?.index ?: 0) to - monthIndexForItem(visible.lastOrNull()?.index ?: 0) + if (visible.isEmpty()) { + null + } else { + monthIndexForItem(visible.first().index) to + monthIndexForItem(visible.last().index) + } } + .filterNotNull() .distinctUntilChanged() .collect { (first, last) -> viewModel.onVisibleMonthsChanged(first, last) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt index dd3e64b..d94d4b5 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt @@ -148,7 +148,8 @@ class MonthViewModel @Inject constructor( * close enough to a loaded edge to warrant a wider query. */ fun onVisibleMonthsChanged(firstIndex: Int, lastIndex: Int) { - nextLoadWindow(_loadedMonths.value, firstIndex, lastIndex)?.let { _loadedMonths.value = it } + nextLoadWindow(_loadedMonths.value, firstIndex, lastIndex) + ?.let { _loadedMonths.value = clampMonthWindow(it) } } private fun buildContinuousState( @@ -402,6 +403,16 @@ internal fun itemIndexForMonth(monthIndex: Int): Int = monthIndex * 2 internal fun monthIndexForItem(itemIndex: Int): Int = itemIndex / 2 +/** + * Hold a load window inside the months that actually exist. The pad + * [nextLoadWindow] adds can otherwise push an index past either end of the + * 1900–2100 span, and [yearMonthForIndex] has no month to hand back for one. + */ +internal fun clampMonthWindow(window: IntRange): IntRange { + val last = continuousMonthCount() - 1 + return window.first.coerceIn(0, last)..window.last.coerceIn(0, last) +} + internal fun firstOfMonth(ym: YearMonth): LocalDate = LocalDate(ym.year, ym.month, 1) /** How many week rows [ym] needs under [weekStart] — 4 (a non-leap February), 5 or 6. */ diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt index 22e0529..c637c3d 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt @@ -88,6 +88,25 @@ class ContinuousMonthIndexTest { } } + @Test + fun `a window is clamped to the months that exist`() { + // The regression this guards: an empty visible-items list reported as + // "month 0", whose padded window ran to -4 — and yearMonthForIndex has + // no month for that, so the whole grid failed with "couldn't read". + assertThat(clampMonthWindow(-4..4)).isEqualTo(0..4) + val last = continuousMonthCount() - 1 + assertThat(clampMonthWindow((last - 2)..(last + 4))).isEqualTo((last - 2)..last) + assertThat(clampMonthWindow(10..20)).isEqualTo(10..20) + } + + @Test + fun `every index a clamped window can hold resolves to a month`() { + listOf(clampMonthWindow(-4..4), clampMonthWindow((continuousMonthCount() - 1)..(continuousMonthCount() + 3))) + .forEach { window -> + window.forEach { index -> yearMonthForIndex(index) } + } + } + @Test fun `firstOfMonth is the 1st`() { assertThat(firstOfMonth(jun26)).isEqualTo(LocalDate(2026, 6, 1))