fix(month): don't drag the continuous window back to 1900 (#38)

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) <noreply@anthropic.com>
This commit is contained in:
2026-07-20 17:59:53 +02:00
parent 6e3d10858f
commit ee9d65068d
3 changed files with 42 additions and 3 deletions

View File

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

View File

@@ -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
* 19002100 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. */

View File

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