From 6e3d10858fd7e0f3d1e527b9d1a66e09613a3c53 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 20 Jul 2026 17:54:26 +0200 Subject: [PATCH] refactor(month): make the continuous style a stack of month blocks (#38) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The continuous style streamed weeks with no boundaries at all, which left it hard to tell where one month ended: the only marker was a "Jul 1" label on the 1st, and every boundary week mixed two months' days into one row. It is now a vertical stack of self-contained month blocks. Each block shows only its own days — the boundary week keeps its seven columns so nothing shifts sideways, but the neighbour month's cells are blank rather than filled with duplicates of days shown again a block later — under a sticky month header with whitespace either side. Scrolling stays continuous; only the reading changes. - The coordinate space moves from absolute week index to absolute month index (two LazyColumn items per month: header, then block). Unlike week indices, it doesn't depend on the week-start preference, so changing that reflows the rows inside a block without moving the block or losing the scroll position. - The sliding data window now loads months rather than weeks, widened to whole grid weeks at both ends so a bar reaching into a block from a clipped-off day still renders. - `clipWeekToMonth` is the pure seam: it drops the neighbour month's pills and counts and cuts spanning bars back to the month's own columns, keeping a flat cap on the cut side so a bar reads as continuing past the block. - The top bar carries the year in this style — the block's own header names the month, so repeating it two lines up was pure duplication. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/month/MonthScreen.kt | 265 +++++++++++------- .../calendula/ui/month/MonthStylePreview.kt | 23 +- .../calendula/ui/month/MonthUiState.kt | 15 +- .../calendula/ui/month/MonthViewModel.kt | 161 +++++++---- app/src/main/res/values/strings.xml | 2 +- .../calendula/ui/month/ClipWeekToMonthTest.kt | 120 ++++++++ .../ui/month/ContinuousMonthIndexTest.kt | 138 +++++++++ .../ui/month/ContinuousWeekIndexTest.kt | 121 -------- 8 files changed, 551 insertions(+), 294 deletions(-) create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ClipWeekToMonthTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt delete mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousWeekIndexTest.kt 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 d6100b7..40474b5 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 @@ -23,7 +23,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.selection.selectable @@ -49,7 +48,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.key import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf @@ -104,12 +102,10 @@ import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.floret.time.isoWeekNumber import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch -import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DayOfWeek import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone import kotlinx.datetime.YearMonth -import kotlinx.datetime.plus import kotlinx.datetime.toJavaLocalDate import kotlinx.datetime.toLocalDateTime import kotlin.time.Clock @@ -161,24 +157,17 @@ fun MonthScreen( else -> (state as? MonthUiState.Success)?.today } ?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date - // Keyed on the week start: week indices are anchored on it, so a changed - // week start would leave the saved scroll offset pointing at a different week. - val listState = key(weekStart) { - rememberLazyListState( - initialFirstVisibleItemIndex = - weekIndexOf(LocalDate(month.year, month.month, 1), weekStart), - ) - } + // Month indices don't move with the week-start preference, so unlike the + // week-indexed stream this replaced, the list state survives a change to it. + val listState = rememberLazyListState( + initialFirstVisibleItemIndex = itemIndexForMonth(monthIndexOf(month)), + ) - // The continuous stream has no single "current" month, so the title takes the - // one the viewport mostly sits in: the midweek day of the row below the top - // edge, which flips over as that month's first full week comes into view. - val visibleMonth by remember(weekStart) { - derivedStateOf { - val midweek = weekStartForIndex(listState.firstVisibleItemIndex + 1, weekStart) - .plus(3, DateTimeUnit.DAY) - YearMonth(midweek.year, midweek.month) - } + // Whichever month's block the top of the viewport is in. Its sticky header + // is the first visible item for all but the last sliver of a block, so this + // flips exactly when the header does. + val visibleMonth by remember { + derivedStateOf { yearMonthForIndex(monthIndexForItem(listState.firstVisibleItemIndex)) } } val titleMonth = if (continuous) visibleMonth else month @@ -188,16 +177,23 @@ fun MonthScreen( if (!continuous) return@LaunchedEffect snapshotFlow { val visible = listState.layoutInfo.visibleItemsInfo - (visible.firstOrNull()?.index ?: 0) to (visible.lastOrNull()?.index ?: 0) + monthIndexForItem(visible.firstOrNull()?.index ?: 0) to + monthIndexForItem(visible.lastOrNull()?.index ?: 0) } .distinctUntilChanged() - .collect { (first, last) -> viewModel.onVisibleWeeksChanged(first, last) } + .collect { (first, last) -> viewModel.onVisibleMonthsChanged(first, last) } } val isOnCurrentMonth = titleMonth == YearMonth(today.year, today.month) - // Drives whether the title carries the year. - val currentYear = today.year + // The continuous style names the month on the block's own sticky header, so + // the bar carries the year instead of repeating it two lines further down. + val locale = currentLocale() + val topBarTitle = if (continuous) { + titleMonth.year.toString() + } else { + formatMonthTitle(titleMonth, locale, currentYear = today.year) + } // Slide direction for the grid transition: +1 = next, -1 = prev, 0 = jump (no slide). var slideDir by remember { mutableIntStateOf(0) } @@ -215,7 +211,11 @@ fun MonthScreen( // scrolls to today's week instead — there is nothing to slide. val jumpToToday = { if (continuous) { - scope.launch { listState.animateScrollToItem(weekIndexOf(today, weekStart)) } + scope.launch { + listState.animateScrollToItem( + itemIndexForMonth(monthIndexOf(YearMonth(today.year, today.month))), + ) + } Unit } else { slideDir = when (val s = state) { @@ -231,7 +231,7 @@ fun MonthScreen( if (continuous) { scope.launch { listState.animateScrollToItem( - weekIndexOf(LocalDate(target.year, target.month, 1), weekStart), + itemIndexForMonth(monthIndexOf(YearMonth(target.year, target.month))), ) } } else { @@ -268,8 +268,7 @@ fun MonthScreen( modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { MonthTopBar( - month = titleMonth, - currentYear = currentYear, + title = topBarTitle, selectedView = selectedView, onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) }, onOpenDrawer = { scope.launch { drawerState.open() } }, @@ -409,8 +408,7 @@ private fun ContinuousMonthContent( @OptIn(ExperimentalMaterial3Api::class) @Composable private fun MonthTopBar( - month: YearMonth, - currentYear: Int, + title: String, selectedView: CalendarView, onCycleView: () -> Unit, onOpenDrawer: () -> Unit, @@ -419,11 +417,10 @@ private fun MonthTopBar( onToday: () -> Unit, scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior, ) { - val locale = currentLocale() TopAppBar( title = { Text( - text = formatMonthTitle(month, locale, currentYear), + text = title, style = MaterialTheme.typography.titleLarge, ) }, @@ -502,6 +499,14 @@ private const val MAX_EVENT_ROWS = 3 */ private val CONTINUOUS_ROW_HEIGHT = 112.dp +/** + * Whitespace below a month block, before the next month's header. Paired with the + * header's own top padding it gives the seam between two months roughly a row's + * worth of air — enough that the blocks read as separate without a rule between + * them. + */ +private val CONTINUOUS_MONTH_GAP = 20.dp + @Composable internal fun MonthGrid( state: MonthUiState.Success, @@ -534,15 +539,19 @@ internal fun MonthGrid( } /** - * The continuous grid (#38): one uninterrupted vertical stream of weeks. + * The continuous grid (#38): months stacked into one vertical scroll, each a + * self-contained block under its own sticky header. * - * The list is indexed by *absolute week number*, so a row's identity never - * changes and there is no page seam to scroll across. Weeks the loaded window - * hasn't reached yet render as a skeleton and pull the window along behind them. + * A block shows *only its own days* — the boundary week keeps its seven columns + * so nothing shifts sideways, but the neighbour month's cells are left blank + * rather than filled with dimmed duplicates of days shown again a block later. + * The whitespace either side of a header is what separates one month from the + * next; scrolling itself never stops at a page seam. * - * Deliberately not a stack of month grids — that would still repeat a boundary - * week at the end of one month and the start of the next, which is exactly the - * duplication #38 asks to be rid of. + * The list is indexed by *absolute month index* (two items per month: header, + * then block), so a block's identity never changes and the week-start preference + * can't move it. Months the loaded window hasn't reached render as a skeleton of + * the right height and pull the window along behind them. */ @Composable internal fun ContinuousMonthGrid( @@ -552,30 +561,72 @@ internal fun ContinuousMonthGrid( onOpenDay: (LocalDate) -> Unit, modifier: Modifier = Modifier, ) { - val weekCount = remember(state.weekStart) { continuousWeekCount(state.weekStart) } + val monthCount = remember { continuousMonthCount() } + val todayMonth = remember(state.today) { YearMonth(state.today.year, state.today.month) } LazyColumn( state = listState, - modifier = modifier - .fillMaxSize() - .padding(horizontal = 8.dp), - verticalArrangement = Arrangement.spacedBy(2.dp), + modifier = modifier.fillMaxSize(), // Bottom inset clears the FAB stack so the last row stays tappable. - contentPadding = PaddingValues(top = 4.dp, bottom = 96.dp), + contentPadding = PaddingValues(bottom = 96.dp), ) { - items(count = weekCount, key = { it }) { index -> - val week = state.weeksByIndex[index] - if (week == null) { - ContinuousWeekPlaceholder() - } else { - MonthWeekRow( - week = week, + repeat(monthCount) { index -> + val month = yearMonthForIndex(index) + stickyHeader(key = "header-$index", contentType = "month-header") { + ContinuousMonthHeader( + month = month, + currentYear = state.today.year, + isCurrent = month == todayMonth, + ) + } + item(key = "month-$index", contentType = "month-block") { + ContinuousMonthBlock( + month = month, + weeks = state.monthsByIndex[index], + weekStart = state.weekStart, today = state.today, - // Every day in the stream belongs to a month equally — there - // is no "other month" to recede here. - inMonth = { true }, showWeekNumbers = showWeekNumbers, onOpenDay = onOpenDay, - labelMonthOnFirst = true, + ) + } + } + } +} + +/** + * One month's week rows. Sized by [weekRowsInMonth] even before its data has + * loaded, so the skeleton occupies exactly what the real block will and a scroll + * across unloaded months doesn't jump when they arrive. + */ +@Composable +private fun ContinuousMonthBlock( + month: YearMonth, + weeks: List?, + weekStart: DayOfWeek, + today: LocalDate, + showWeekNumbers: Boolean, + onOpenDay: (LocalDate) -> Unit, +) { + val rowCount = remember(month, weekStart) { weekRowsInMonth(month, weekStart) } + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) + .padding(bottom = CONTINUOUS_MONTH_GAP), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + if (weeks == null) { + repeat(rowCount) { ContinuousWeekPlaceholder() } + } else { + weeks.forEach { week -> + MonthWeekRow( + week = week, + today = today, + inMonth = { it.month == month.month && it.year == month.year }, + // The block owns its month alone: a day from either + // neighbour is left out entirely rather than dimmed. + blankOutside = true, + showWeekNumbers = showWeekNumbers, + onOpenDay = onOpenDay, modifier = Modifier .fillMaxWidth() .height(CONTINUOUS_ROW_HEIGHT), @@ -585,6 +636,27 @@ internal fun ContinuousMonthGrid( } } +/** + * The month label that pins above its block. Opaque `surface` so the rows scroll + * under it cleanly, and generous vertical padding — the whitespace around the + * label is what makes each month read as its own section. + */ +@Composable +private fun ContinuousMonthHeader(month: YearMonth, currentYear: Int, isCurrent: Boolean) { + val locale = currentLocale() + Text( + text = formatMonthTitle(month, locale, currentYear), + style = MaterialTheme.typography.titleMedium, + color = if (isCurrent) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + .padding(horizontal = 12.dp) + .padding(top = 20.dp, bottom = 10.dp), + ) +} + /** * The month-changing horizontal swipe, shared by the paged and split styles. * Accumulates the drag and commits past a small threshold on release — the grid @@ -888,7 +960,7 @@ private fun MonthWeekRow( showWeekNumbers: Boolean, onOpenDay: (LocalDate) -> Unit, modifier: Modifier = Modifier, - labelMonthOnFirst: Boolean = false, + blankOutside: Boolean = false, ) { val dark = isSystemInDarkTheme() val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1 @@ -925,8 +997,13 @@ private fun MonthWeekRow( .fillMaxHeight() .padding(horizontal = CELL_GAP, vertical = 1.dp) .background( - color = if (inMonth(d)) MaterialTheme.colorScheme.surfaceContainer - else MaterialTheme.colorScheme.surfaceContainerLow, + color = when { + inMonth(d) -> MaterialTheme.colorScheme.surfaceContainer + // A blanked cell draws nothing at all: it is + // the gap that shows where the month starts. + blankOutside -> Color.Transparent + else -> MaterialTheme.colorScheme.surfaceContainerLow + }, shape = CELL_SHAPE, ), ) @@ -936,20 +1013,16 @@ private fun MonthWeekRow( Column(Modifier.fillMaxSize().padding(top = CELL_TOP_PADDING)) { Row(Modifier.fillMaxWidth()) { week.days.forEach { d -> - DayNumberCell( - date = d, - isToday = d == today, - inMonth = inMonth(d), - // Continuous has no month boundaries to dim across, so - // the 1st names its month instead — the only marker - // telling one month from the next inside the stream. - monthLabel = if (labelMonthOnFirst && d.day == 1) { - shortMonthName(d) - } else { - null - }, - modifier = Modifier.weight(1f), - ) + if (blankOutside && !inMonth(d)) { + Spacer(Modifier.weight(1f).height(DAY_NUMBER_HEIGHT)) + } else { + DayNumberCell( + date = d, + isToday = d == today, + inMonth = inMonth(d), + modifier = Modifier.weight(1f), + ) + } } } // Breathing room between the day number (and today's circle) and the @@ -1030,17 +1103,22 @@ private fun MonthWeekRow( } // Tap layer: in month view a tap on any day opens that day. Padded and - // clipped to the background pill so the ripple matches it. + // clipped to the background pill so the ripple matches it. A blanked + // cell isn't part of this month, so it takes no taps either. Row(Modifier.matchParentSize()) { week.days.forEach { d -> - Box( - Modifier - .weight(1f) - .fillMaxHeight() - .padding(horizontal = CELL_GAP, vertical = 1.dp) - .clip(CELL_SHAPE) - .clickable { onOpenDay(d) }, - ) + if (blankOutside && !inMonth(d)) { + Spacer(Modifier.weight(1f).fillMaxHeight()) + } else { + Box( + Modifier + .weight(1f) + .fillMaxHeight() + .padding(horizontal = CELL_GAP, vertical = 1.dp) + .clip(CELL_SHAPE) + .clickable { onOpenDay(d) }, + ) + } } } } @@ -1080,7 +1158,6 @@ private fun DayNumberCell( isToday: Boolean, inMonth: Boolean, modifier: Modifier = Modifier, - monthLabel: String? = null, ) { Box( modifier = modifier.height(DAY_NUMBER_HEIGHT), @@ -1100,16 +1177,6 @@ private fun DayNumberCell( color = MaterialTheme.colorScheme.onPrimary, ) } - } else if (monthLabel != null) { - Text( - text = "$monthLabel ${date.day}", - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary, - maxLines = 1, - overflow = TextOverflow.Visible, - softWrap = false, - ) } else { Text( text = date.day.toString(), @@ -1121,16 +1188,6 @@ private fun DayNumberCell( } } -/** Locale-short month name ("Jul", "Juli"), for the 1st in the continuous grid. */ -@Composable -private fun shortMonthName(date: LocalDate): String { - val locale = currentLocale() - return remember(date.month, locale) { - java.time.Month.of(date.month.ordinal + 1) - .getDisplayName(JavaTextStyle.SHORT, locale) - } -} - /** A filled event pill/bar — softened (or raw) fill, title clipped to one line. */ @Composable private fun MonthBar( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthStylePreview.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthStylePreview.kt index b27b867..d113cf3 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthStylePreview.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthStylePreview.kt @@ -112,8 +112,9 @@ internal fun MonthStylePreview( MonthViewStyle.Continuous -> ContinuousMonthGrid( state = sample.continuous, listState = rememberLazyListState( - initialFirstVisibleItemIndex = - weekIndexOf(LocalDate(today.year, today.month, 1), weekStart), + initialFirstVisibleItemIndex = itemIndexForMonth( + monthIndexOf(YearMonth(today.year, today.month)), + ), ), showWeekNumbers = false, onOpenDay = {}, @@ -157,7 +158,7 @@ private class SampleMonth( /** * A month's worth of stand-in events, laid out through the same - * [layoutMonthWeeks] / [layoutCalendarWeek] the live views use — so the preview + * [layoutMonthWeeks] / [clipWeekToMonth] the live views use — so the preview * exercises the real span, lane and overflow logic rather than approximating it. * * Colours are raw ARGB on purpose: that is what the provider hands out for an @@ -181,17 +182,15 @@ private fun sampleMonthState( zone = zone, ) - // Enough weeks either side of today for the continuous list to fill the - // viewport and still have somewhere to scroll. - val centre = weekIndexOf(today, weekStart) - val window = (centre - 8)..(centre + 8) + // This month and the next: enough for the preview to show a month block, the + // whitespace under it and the following month's header coming up. + val centre = monthIndexOf(ym) + val window = centre..(centre + 1) val continuous = ContinuousMonthUiState.Success( today = today, - weeksByIndex = window.associateWith { index -> - val days = (0 until 7).map { - weekStartForIndex(index, weekStart).plus(it, DateTimeUnit.DAY) - } - layoutCalendarWeek(days, events, zone) + monthsByIndex = window.associateWith { index -> + val m = yearMonthForIndex(index) + layoutMonthWeeks(m, weekStart, events, zone).map { clipWeekToMonth(it, m) } }, weekStart = weekStart, ) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt index cb59ccf..91ff84a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt @@ -40,18 +40,21 @@ data class MonthWeek( ) /** - * State for the continuous style (#38). Weeks are keyed by absolute week index - * rather than gathered into months: the whole point of the style is that there - * are no month boundaries to scroll across, so there is no "current month" here - * and no notion of an out-of-month day. [weeksByIndex] holds only the loaded - * window; indices outside it render as placeholders until the window catches up. + * State for the continuous style (#38): a vertical stream of *self-contained* + * months rather than one undifferentiated run of weeks. Each month is keyed by + * absolute month index and carries only its own days — the boundary week keeps + * its seven columns so the grid geometry never shifts, but the neighbour month's + * dates are clipped out of it (see `clipWeekToMonth`) and render as blanks. + * + * [monthsByIndex] holds only the loaded window; indices outside it render as + * placeholders until the window catches up. */ sealed interface ContinuousMonthUiState { data object Loading : ContinuousMonthUiState data class Failure(val reason: FailureReason) : ContinuousMonthUiState data class Success( val today: LocalDate, - val weeksByIndex: Map, + val monthsByIndex: Map>, val weekStart: DayOfWeek, ) : ContinuousMonthUiState } 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 81f33ad..dd3e64b 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 @@ -25,11 +25,11 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DayOfWeek import kotlinx.datetime.LocalDate +import kotlinx.datetime.Month import kotlinx.datetime.TimeZone import kotlinx.datetime.YearMonth import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.atTime -import kotlinx.datetime.daysUntil import kotlinx.datetime.minus import kotlinx.datetime.plus import kotlinx.datetime.toInstant @@ -102,24 +102,30 @@ class MonthViewModel @Inject constructor( // --- Continuous style (#38) ------------------------------------------- // - // The continuous grid is one endless stream of weeks, so it can't load "a - // month" — it loads a sliding window of week indices around whatever is on - // screen. The window only moves when the visible range comes within - // WINDOW_EDGE weeks of a loaded edge, so a scroll re-queries occasionally - // rather than on every frame. + // The continuous grid scrolls through every month there is, so it can't load + // one month at a time — it loads a sliding window of month indices around + // whatever is on screen. The window only moves when the visible range comes + // within WINDOW_EDGE months of a loaded edge, so a scroll re-queries + // occasionally rather than on every frame. - // Seeded around today so the first frame has data. The week-start preference - // hasn't arrived yet, but anchoring on a different day shifts an index by at - // most one week — well inside the pad, and the first scroll report corrects it. - private val _loadedWeeks = MutableStateFlow( - weekIndexOf(todayDate, DayOfWeek.MONDAY).let { it - WINDOW_PAD..it + WINDOW_PAD }, + // Seeded around today so the first frame has data. + private val _loadedMonths = MutableStateFlow( + monthIndexOf(YearMonth(todayDate.year, todayDate.month)) + .let { it - WINDOW_PAD..it + WINDOW_PAD }, ) val continuousState: StateFlow = - combine(_loadedWeeks, weekStart) { window, ws -> window to ws } + combine(_loadedMonths, weekStart) { window, ws -> window to ws } .flatMapLatest { (window, ws) -> - val first = weekStartForIndex(window.first, ws) - val last = weekStartForIndex(window.last, ws).plus(6, DateTimeUnit.DAY) + // Widened to whole grid weeks at both ends: a month block still + // has to know about an event that starts in the boundary week's + // clipped-off days, or a bar running into the block would vanish. + val first = firstOfMonth(yearMonthForIndex(window.first)).startOfGridWeek(ws) + val last = firstOfMonth(yearMonthForIndex(window.last)) + .plus(1, DateTimeUnit.MONTH) + .minus(1, DateTimeUnit.DAY) + .startOfGridWeek(ws) + .plus(6, DateTimeUnit.DAY) val range = first.atStartOfDayIn(zone)..last.atTime(23, 59, 59).toInstant(zone) combine( repository.calendars(), @@ -137,12 +143,12 @@ class MonthViewModel @Inject constructor( ) /** - * Report which absolute week indices are on screen. Cheap to call on every + * Report which absolute month indices are on screen. Cheap to call on every * scroll frame: it returns immediately unless the visible range has drifted * close enough to a loaded edge to warrant a wider query. */ - fun onVisibleWeeksChanged(firstIndex: Int, lastIndex: Int) { - nextLoadWindow(_loadedWeeks.value, firstIndex, lastIndex)?.let { _loadedWeeks.value = it } + fun onVisibleMonthsChanged(firstIndex: Int, lastIndex: Int) { + nextLoadWindow(_loadedMonths.value, firstIndex, lastIndex)?.let { _loadedMonths.value = it } } private fun buildContinuousState( @@ -154,15 +160,13 @@ class MonthViewModel @Inject constructor( if (calendars.isEmpty()) { return ContinuousMonthUiState.Failure(FailureReason.NoCalendarsConfigured) } - val weeks = window.associateWith { index -> - val days = (0 until 7).map { - weekStartForIndex(index, weekStart).plus(it, DateTimeUnit.DAY) - } - layoutCalendarWeek(days, instances, zone) + val months = window.associateWith { index -> + val ym = yearMonthForIndex(index) + layoutMonthWeeks(ym, weekStart, instances, zone).map { clipWeekToMonth(it, ym) } } return ContinuousMonthUiState.Success( today = todayDate, - weeksByIndex = weeks, + monthsByIndex = months, weekStart = weekStart, ) } @@ -252,12 +256,8 @@ internal fun layoutMonthWeeks( instances: List, zone: TimeZone, ): List { - val firstOfMonth = LocalDate(ym.year, ym.month, 1) - val gridStart = firstOfMonth.startOfGridWeek(weekStart) - val leadOffset = ((firstOfMonth.dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7 - val daysInMonth = - java.time.YearMonth.of(ym.year, ym.month.ordinal + 1).lengthOfMonth() - val weekCount = (leadOffset + daysInMonth + 6) / 7 + val gridStart = firstOfMonth(ym).startOfGridWeek(weekStart) + val weekCount = weekRowsInMonth(ym, weekStart) return (0 until weekCount).map { row -> val days = (0 until 7).map { gridStart.plus(row * 7 + it, DateTimeUnit.DAY) } @@ -336,13 +336,13 @@ internal fun monthGridRange( } /** - * How many weeks the continuous window loads beyond the visible range, and how + * How many months the continuous window loads beyond the visible range, and how * close the visible range may drift to a loaded edge before it reloads. The pad * is generous relative to the trigger so a steady scroll crosses the trigger - * well before it would run out of laid-out weeks. + * well before it would run out of laid-out months. */ -private const val WINDOW_PAD = 12 -private const val WINDOW_EDGE = 4 +private const val WINDOW_PAD = 4 +private const val WINDOW_EDGE = 2 /** * Which day the split style should select when the grid lands on [month]: @@ -370,28 +370,89 @@ internal fun nextLoadWindow(loaded: IntRange, firstVisible: Int, lastVisible: In } /** - * The continuous grid addresses weeks by an absolute index rather than a - * (month, row) pair, so the list has one stable, gap-free coordinate space to - * scroll through and key its items by. Index 0 is the first week of 1900 under - * the active week-start; the list runs to [CONTINUOUS_WEEK_COUNT]. + * The continuous grid addresses months by an absolute index, so the list has one + * stable, gap-free coordinate space to scroll through and key its items by. + * Index 0 is January 1900; the list runs to [continuousMonthCount]. * - * The epoch is deliberately far in the past so every index is non-negative, - * which keeps the LazyColumn's item indices and week indices the same number. + * Unlike the week indexing this replaced, the coordinate space doesn't depend on + * the week-start preference — changing it reflows the rows *inside* a month + * block but never moves the block, so a scroll position stays put. */ -private val WEEK_INDEX_EPOCH = LocalDate(1900, 1, 1) -private val WEEK_INDEX_END = LocalDate(2100, 12, 31) +private const val MONTH_INDEX_EPOCH_YEAR = 1900 +private const val MONTH_INDEX_END_YEAR = 2100 -internal fun weekIndexOf(date: LocalDate, weekStart: DayOfWeek): Int { - val base = WEEK_INDEX_EPOCH.startOfGridWeek(weekStart) - return base.daysUntil(date.startOfGridWeek(weekStart)) / 7 +internal fun monthIndexOf(ym: YearMonth): Int = + (ym.year - MONTH_INDEX_EPOCH_YEAR) * 12 + ym.month.ordinal + +internal fun yearMonthForIndex(index: Int): YearMonth = YearMonth( + MONTH_INDEX_EPOCH_YEAR + index / 12, + Month.entries[index % 12], +) + +/** Total months the continuous grid scrolls through (1900 → 2100). */ +internal fun continuousMonthCount(): Int = + (MONTH_INDEX_END_YEAR - MONTH_INDEX_EPOCH_YEAR + 1) * 12 + +/** + * Each month occupies two LazyColumn items — its sticky header and the block of + * week rows under it — so the two coordinate spaces differ by a factor of two. + * Scrolling to a month means scrolling to its header. + */ +internal fun itemIndexForMonth(monthIndex: Int): Int = monthIndex * 2 + +internal fun monthIndexForItem(itemIndex: Int): Int = itemIndex / 2 + +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. */ +internal fun weekRowsInMonth(ym: YearMonth, weekStart: DayOfWeek): Int { + val first = firstOfMonth(ym) + val leadOffset = ((first.dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7 + val daysInMonth = java.time.YearMonth.of(ym.year, ym.month.ordinal + 1).lengthOfMonth() + return (leadOffset + daysInMonth + 6) / 7 } -internal fun weekStartForIndex(index: Int, weekStart: DayOfWeek): LocalDate = - WEEK_INDEX_EPOCH.startOfGridWeek(weekStart).plus(index * 7, DateTimeUnit.DAY) - -/** Total weeks the continuous grid scrolls through (1900 → 2100). */ -internal fun continuousWeekCount(weekStart: DayOfWeek): Int = - weekIndexOf(WEEK_INDEX_END, weekStart) + 1 +/** + * Strip everything outside [month] from a boundary week row, for the continuous + * style's self-contained month blocks. + * + * The seven [MonthWeek.days] stay put — the row keeps its column geometry, and + * the grid decides which cells to draw blank — but the neighbour month's events + * are removed: its pills and counts go, and a bar reaching in from (or out into) + * it is cut back to the month's own columns. A bar cut this way keeps a flat + * cap, which is what says "this continues past the block" rather than "it ends + * here". + */ +internal fun clipWeekToMonth(week: MonthWeek, month: YearMonth): MonthWeek { + val inMonth = week.days.map { it.year == month.year && it.month == month.month } + val firstCol = inMonth.indexOfFirst { it } + val lastCol = inMonth.indexOfLast { it } + // Can't happen for a row of the month's own grid, but a caller-proof no-op + // beats an out-of-range crash. + if (firstCol == -1) { + return week.copy(spans = emptyList(), timedByDay = emptyMap(), countByDay = emptyMap()) + } + val spans = week.spans.mapNotNull { span -> + val start = maxOf(span.startCol, firstCol) + val end = minOf(span.endCol, lastCol) + if (start > end) { + null + } else { + span.copy( + startCol = start, + endCol = end, + continuesLeft = span.continuesLeft || start > span.startCol, + continuesRight = span.continuesRight || end < span.endCol, + ) + } + } + val own = week.days.filterIndexed { col, _ -> inMonth[col] } + return week.copy( + spans = spans, + timedByDay = week.timedByDay.filterKeys { it in own }, + countByDay = week.countByDay.filterKeys { it in own }, + ) +} internal fun LocalDate.startOfGridWeek(weekStart: DayOfWeek): LocalDate { // DayOfWeek.ordinal: MONDAY=0..SUNDAY=6 → identical to ISO ordering. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 988f6c7..5bd8dd6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -379,7 +379,7 @@ Pages One month at a time. Swipe sideways to change month. Continuous - Scroll up and down through the weeks. No month is cut off and no day appears twice. + Scroll up and down through the months. Each month keeps to itself under its own heading, with no days borrowed from its neighbours. Split A compact grid with dots for events, and the day you tap listed underneath. Nothing scheduled diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ClipWeekToMonthTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ClipWeekToMonthTest.kt new file mode 100644 index 0000000..3193ded --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ClipWeekToMonthTest.kt @@ -0,0 +1,120 @@ +package de.jeanlucmakiola.calendula.ui.month + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.domain.EventInstance +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import kotlinx.datetime.Month +import kotlinx.datetime.TimeZone +import kotlinx.datetime.YearMonth +import kotlinx.datetime.atTime +import kotlinx.datetime.plus +import kotlinx.datetime.toInstant +import org.junit.jupiter.api.Test + +/** + * What makes the continuous style's month blocks self-contained: a boundary week + * keeps its seven columns but carries only the block's own month. + */ +class ClipWeekToMonthTest { + + private val zone = TimeZone.UTC + private val jul26 = YearMonth(2026, Month.JULY) + + private fun allDay(from: LocalDate, toInclusive: LocalDate, id: Long = 1L) = EventInstance( + instanceId = id, + eventId = id, + calendarId = 1L, + title = "A", + start = from.atTime(0, 0).toInstant(TimeZone.UTC), + end = toInclusive.plus(1, DateTimeUnit.DAY).atTime(0, 0).toInstant(TimeZone.UTC), + isAllDay = true, + color = 0xFF445566.toInt(), + location = null, + ) + + private fun timed(date: LocalDate, hour: Int, id: Long = 2L) = EventInstance( + instanceId = id, + eventId = id, + calendarId = 1L, + title = "T", + start = date.atTime(hour, 0).toInstant(zone), + end = date.atTime(hour + 1, 0).toInstant(zone), + isAllDay = false, + color = 0xFF112233.toInt(), + location = null, + ) + + /** July 2026 starts on a Wednesday, so its first Monday-anchored row is Jun 29 – Jul 5. */ + private fun firstRowOfJuly(events: List) = + clipWeekToMonth( + layoutMonthWeeks(jul26, DayOfWeek.MONDAY, events, zone).first(), + jul26, + ) + + @Test + fun `the row keeps all seven days so the columns don't shift`() { + val week = firstRowOfJuly(emptyList()) + assertThat(week.days).hasSize(7) + assertThat(week.days.first()).isEqualTo(LocalDate(2026, 6, 29)) + } + + @Test + fun `the neighbour month's events are dropped`() { + val june = timed(LocalDate(2026, 6, 30), 9) + val july = timed(LocalDate(2026, 7, 1), 9, id = 3L) + val week = firstRowOfJuly(listOf(june, july)) + + assertThat(week.timedByDay.keys).doesNotContain(LocalDate(2026, 6, 30)) + assertThat(week.timedByDay[LocalDate(2026, 7, 1)]).containsExactly(july) + assertThat(week.countByDay[LocalDate(2026, 6, 30)]).isNull() + assertThat(week.countByDay[LocalDate(2026, 7, 1)]).isEqualTo(1) + } + + @Test + fun `a bar reaching in from the previous month is cut back to the 1st`() { + // Jun 29 – Jul 2: columns 0..3 unclipped, 2..3 once July owns the row. + val week = firstRowOfJuly(listOf(allDay(LocalDate(2026, 6, 29), LocalDate(2026, 7, 2)))) + + val span = week.spans.single() + assertThat(span.startCol).isEqualTo(2) // Wednesday the 1st + assertThat(span.endCol).isEqualTo(3) + // Flat cap on the cut side: the event continues out of this block. + assertThat(span.continuesLeft).isTrue() + assertThat(span.continuesRight).isFalse() + } + + @Test + fun `a bar living entirely in the neighbour month disappears`() { + val week = firstRowOfJuly(listOf(allDay(LocalDate(2026, 6, 29), LocalDate(2026, 6, 30)))) + assertThat(week.spans).isEmpty() + } + + @Test + fun `a bar reaching out into the next month is cut at the last day`() { + // Jul 29 – Aug 2 sits in July's final row (Jul 27 – Aug 2). + val weeks = layoutMonthWeeks( + jul26, + DayOfWeek.MONDAY, + listOf(allDay(LocalDate(2026, 7, 29), LocalDate(2026, 8, 2))), + zone, + ) + val span = clipWeekToMonth(weeks.last(), jul26).spans.single() + assertThat(span.startCol).isEqualTo(2) // Wednesday the 29th + assertThat(span.endCol).isEqualTo(4) // Friday the 31st + assertThat(span.continuesRight).isTrue() + assertThat(span.continuesLeft).isFalse() + } + + @Test + fun `a row wholly inside the month is untouched`() { + val mid = layoutMonthWeeks( + jul26, + DayOfWeek.MONDAY, + listOf(timed(LocalDate(2026, 7, 8), 9), allDay(LocalDate(2026, 7, 7), LocalDate(2026, 7, 9))), + zone, + )[1] + assertThat(clipWeekToMonth(mid, jul26)).isEqualTo(mid) + } +} 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 new file mode 100644 index 0000000..22e0529 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt @@ -0,0 +1,138 @@ +package de.jeanlucmakiola.calendula.ui.month + +import com.google.common.truth.Truth.assertThat +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import kotlinx.datetime.Month +import kotlinx.datetime.TimeZone +import kotlinx.datetime.YearMonth +import org.junit.jupiter.api.Test + +/** + * The continuous grid's coordinate space. Every block's identity — and the + * LazyColumn items it maps to — hangs off this arithmetic, so it gets its own + * tests rather than being exercised only through the UI. + */ +class ContinuousMonthIndexTest { + + private val jun26 = YearMonth(2026, Month.JUNE) + + @Test + fun `consecutive months are consecutive indices`() { + val jun = monthIndexOf(jun26) + assertThat(monthIndexOf(YearMonth(2026, Month.JULY))).isEqualTo(jun + 1) + assertThat(monthIndexOf(YearMonth(2026, Month.MAY))).isEqualTo(jun - 1) + // And across the year boundary. + assertThat(monthIndexOf(YearMonth(2027, Month.JANUARY))) + .isEqualTo(monthIndexOf(YearMonth(2026, Month.DECEMBER)) + 1) + } + + @Test + fun `index round-trips back to its month`() { + listOf( + YearMonth(1900, Month.JANUARY), + jun26, + YearMonth(2026, Month.DECEMBER), + YearMonth(2100, Month.DECEMBER), + ).forEach { ym -> + assertThat(yearMonthForIndex(monthIndexOf(ym))).isEqualTo(ym) + } + } + + @Test + fun `indices are non-negative and span 1900 through 2100`() { + assertThat(monthIndexOf(YearMonth(1900, Month.JANUARY))).isEqualTo(0) + assertThat(monthIndexOf(jun26)).isGreaterThan(0) + assertThat(monthIndexOf(YearMonth(2100, Month.DECEMBER))) + .isEqualTo(continuousMonthCount() - 1) + assertThat(continuousMonthCount()).isEqualTo(201 * 12) + } + + @Test + fun `a month maps to its header item and back`() { + val index = monthIndexOf(jun26) + val item = itemIndexForMonth(index) + // Both of a month's items — its sticky header and the block under it — + // resolve back to the same month, so a title read off the first visible + // item is right wherever the viewport sits inside the block. + assertThat(monthIndexForItem(item)).isEqualTo(index) + assertThat(monthIndexForItem(item + 1)).isEqualTo(index) + assertThat(monthIndexForItem(item + 2)).isEqualTo(index + 1) + } + + @Test + fun `week rows follow the month's shape and its week start`() { + // June 2026 starts on a Monday: 30 days → 5 rows either way. + assertThat(weekRowsInMonth(jun26, DayOfWeek.MONDAY)).isEqualTo(5) + assertThat(weekRowsInMonth(jun26, DayOfWeek.SUNDAY)).isEqualTo(5) + // February 2026 starts on a Sunday: a Sunday-anchored grid fits it in 4 + // rows, a Monday-anchored one needs 5 — the block's height moves with the + // preference even though its position in the list doesn't. + val feb26 = YearMonth(2026, Month.FEBRUARY) + assertThat(weekRowsInMonth(feb26, DayOfWeek.SUNDAY)).isEqualTo(4) + assertThat(weekRowsInMonth(feb26, DayOfWeek.MONDAY)).isEqualTo(5) + // February 2021 starts on a Monday and is 28 days → exactly 4. + assertThat(weekRowsInMonth(YearMonth(2021, Month.FEBRUARY), DayOfWeek.MONDAY)) + .isEqualTo(4) + } + + @Test + fun `the placeholder block is the size the loaded one will be`() { + // Otherwise scrolling across an unloaded month jumps when its data lands. + (0 until 24).forEach { offset -> + val ym = yearMonthForIndex(monthIndexOf(jun26) + offset) + DayOfWeek.entries.forEach { ws -> + assertThat(weekRowsInMonth(ym, ws)) + .isEqualTo(layoutMonthWeeks(ym, ws, emptyList(), TimeZone.UTC).size) + } + } + } + + @Test + fun `firstOfMonth is the 1st`() { + assertThat(firstOfMonth(jun26)).isEqualTo(LocalDate(2026, 6, 1)) + } + + @Test + fun `a window comfortably around the visible range is kept`() { + assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 40, lastVisible = 45)).isNull() + } + + @Test + fun `nearing a loaded edge widens the window around the visible range`() { + // Within a month of the top edge → reload, padded on both sides. + assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 1, lastVisible = 2)) + .isEqualTo(-3..6) + + assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 98, lastVisible = 100)) + .isEqualTo(94..104) + } + + @Test + fun `a jump far outside the window reloads around the destination`() { + assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 500, lastVisible = 501)) + .isEqualTo(496..505) + } + + @Test + fun `the reloaded window always clears the trigger it just crossed`() { + // Otherwise every scroll frame would re-trigger a query. + val window = nextLoadWindow(loaded = 0..100, firstVisible = 1, lastVisible = 2)!! + assertThat(nextLoadWindow(window, firstVisible = 1, lastVisible = 2)).isNull() + } + + @Test + fun `the split selection follows the month, landing on today when it's there`() { + val today = LocalDate(2026, 6, 10) + assertThat(selectionForMonth(jun26, today)).isEqualTo(today) + } + + @Test + fun `the split selection falls to the 1st of any other month`() { + val today = LocalDate(2026, 6, 10) + assertThat(selectionForMonth(YearMonth(2026, Month.JULY), today)) + .isEqualTo(LocalDate(2026, 7, 1)) + assertThat(selectionForMonth(YearMonth(2025, Month.JUNE), today)) + .isEqualTo(LocalDate(2025, 6, 1)) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousWeekIndexTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousWeekIndexTest.kt deleted file mode 100644 index 2b17915..0000000 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousWeekIndexTest.kt +++ /dev/null @@ -1,121 +0,0 @@ -package de.jeanlucmakiola.calendula.ui.month - -import com.google.common.truth.Truth.assertThat -import kotlinx.datetime.DateTimeUnit -import kotlinx.datetime.DayOfWeek -import kotlinx.datetime.LocalDate -import kotlinx.datetime.YearMonth -import kotlinx.datetime.plus -import org.junit.jupiter.api.Test - -/** - * The continuous grid's coordinate space. Every row's identity — and the - * LazyColumn item it maps to — hangs off this arithmetic, so it gets its own - * tests rather than being exercised only through the UI. - */ -class ContinuousWeekIndexTest { - - // 2026-06-08 is a Monday; 2026-06-10 the Wednesday of the same week. - private val mon = LocalDate(2026, 6, 8) - private val wed = LocalDate(2026, 6, 10) - - @Test - fun `every day of a week shares one index`() { - val indices = (0..6).map { weekIndexOf(mon.plus(it, DateTimeUnit.DAY), DayOfWeek.MONDAY) } - assertThat(indices.toSet()).hasSize(1) - } - - @Test - fun `consecutive weeks are consecutive indices`() { - val a = weekIndexOf(mon, DayOfWeek.MONDAY) - val b = weekIndexOf(mon.plus(7, DateTimeUnit.DAY), DayOfWeek.MONDAY) - val back = weekIndexOf(mon.plus(-7, DateTimeUnit.DAY), DayOfWeek.MONDAY) - assertThat(b).isEqualTo(a + 1) - assertThat(back).isEqualTo(a - 1) - } - - @Test - fun `index round-trips back to the week's first day`() { - DayOfWeek.entries.forEach { ws -> - val index = weekIndexOf(wed, ws) - assertThat(weekStartForIndex(index, ws)).isEqualTo(wed.startOfGridWeek(ws)) - } - } - - @Test - fun `the week start shifts which week a boundary day belongs to`() { - // Sunday the 14th closes the Monday-anchored week but opens the Sunday one. - val sun = LocalDate(2026, 6, 14) - assertThat(weekIndexOf(sun, DayOfWeek.MONDAY)) - .isEqualTo(weekIndexOf(mon, DayOfWeek.MONDAY)) - assertThat(weekIndexOf(sun, DayOfWeek.SUNDAY)) - .isEqualTo(weekIndexOf(sun.plus(1, DateTimeUnit.DAY), DayOfWeek.SUNDAY)) - } - - @Test - fun `indices are non-negative across the supported span`() { - // The epoch sits before any date the grid scrolls to, so item indices and - // week indices stay the same number — no offset to reconcile. - DayOfWeek.entries.forEach { ws -> - assertThat(weekIndexOf(LocalDate(1900, 1, 1), ws)).isAtLeast(0) - assertThat(weekIndexOf(LocalDate(2026, 6, 8), ws)).isGreaterThan(0) - } - } - - @Test - fun `the list spans 1900 through 2100`() { - DayOfWeek.entries.forEach { ws -> - val count = continuousWeekCount(ws) - assertThat(weekIndexOf(LocalDate(2100, 12, 31), ws)).isLessThan(count) - assertThat(weekIndexOf(LocalDate(2026, 6, 8), ws)).isLessThan(count) - // ~200 years of weeks, give or take the anchor. - assertThat(count).isIn(10_400..10_500) - } - } - - @Test - fun `a window comfortably around the visible range is kept`() { - assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 40, lastVisible = 45)).isNull() - } - - @Test - fun `nearing a loaded edge widens the window around the visible range`() { - // Within four weeks of the top edge → reload, padded on both sides. - val widened = nextLoadWindow(loaded = 0..100, firstVisible = 2, lastVisible = 7) - assertThat(widened).isEqualTo(-10..19) - - val atBottom = nextLoadWindow(loaded = 0..100, firstVisible = 94, lastVisible = 99) - assertThat(atBottom).isEqualTo(82..111) - } - - @Test - fun `a jump far outside the window reloads around the destination`() { - assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 500, lastVisible = 505)) - .isEqualTo(488..517) - } - - @Test - fun `the reloaded window always clears the trigger it just crossed`() { - // Otherwise every scroll frame would re-trigger a query. - var loaded = 0..100 - val window = nextLoadWindow(loaded, firstVisible = 2, lastVisible = 7)!! - loaded = window - assertThat(nextLoadWindow(loaded, firstVisible = 2, lastVisible = 7)).isNull() - } - - @Test - fun `the split selection follows the month, landing on today when it's there`() { - val today = LocalDate(2026, 6, 10) - assertThat(selectionForMonth(YearMonth(2026, kotlinx.datetime.Month.JUNE), today)) - .isEqualTo(today) - } - - @Test - fun `the split selection falls to the 1st of any other month`() { - val today = LocalDate(2026, 6, 10) - assertThat(selectionForMonth(YearMonth(2026, kotlinx.datetime.Month.JULY), today)) - .isEqualTo(LocalDate(2026, 7, 1)) - assertThat(selectionForMonth(YearMonth(2025, kotlinx.datetime.Month.JUNE), today)) - .isEqualTo(LocalDate(2025, 6, 1)) - } -}