feat(month): add the Dense style, rule under the Continuous header (#38)

Continuous's month blocks make the boundaries legible, but the seams are exactly
what someone who wants a dense calendar doesn't want. Both are now offered:

- **Dense** is the old flowing layout, kept rather than replaced — one
  uninterrupted stream of weeks with months running into each other, the 1st
  naming its month, and the top bar keeping the full month title (there is no
  sticky header to defer to).
- Continuous gains a **rule under its month label**, closing the header off
  against the grid. On trial: it comes out again if it doesn't earn its place.

One provider query serves both. `ContinuousMonthUiState.Success` now carries the
same loaded window laid out twice — `monthsByIndex` clipped into blocks,
`weeksByIndex` left whole — rather than standing up a second flow and querying
the same range again. Both styles report *months* as they scroll, so the window
hysteresis is shared; `weekWindowFor` maps a month window onto the Dense rows it
covers, widened a week each side so boundary rows don't flicker.

The list state is keyed on style *and* week start: Dense indexes by week, which
the week-start preference moves, while Continuous indexes by month, which it
doesn't — carrying an offset between the two would land somewhere arbitrary.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-20 18:10:54 +02:00
parent ee9d65068d
commit 24b005126e
7 changed files with 339 additions and 55 deletions

View File

@@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.shape.CircleShape
@@ -48,6 +49,7 @@ 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
@@ -103,9 +105,11 @@ import de.jeanlucmakiola.floret.time.isoWeekNumber
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.launch
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.plus
import kotlinx.datetime.YearMonth
import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.toLocalDateTime
@@ -149,42 +153,61 @@ fun MonthScreen(
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
val continuous = viewStyle == MonthViewStyle.Continuous
val scrolling = viewStyle.isScrolling
val dense = viewStyle == MonthViewStyle.Dense
// Today, from whichever state is driving; the clock only covers the first
// frame, before either has loaded.
val today = when {
continuous -> (continuousState as? ContinuousMonthUiState.Success)?.today
scrolling -> (continuousState as? ContinuousMonthUiState.Success)?.today
else -> (state as? MonthUiState.Success)?.today
} ?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
// 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 two scrolling styles index their items differently — Continuous by
// month (which the week start can't move), Dense by week (which it can) — so
// the list state is rebuilt when either changes rather than carrying an
// offset into a coordinate space that no longer means the same thing.
val listState = key(viewStyle, weekStart) {
rememberLazyListState(
initialFirstVisibleItemIndex = if (dense) {
weekIndexOf(LocalDate(month.year, month.month, 1), weekStart)
} else {
itemIndexForMonth(monthIndexOf(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
// Feed the visible range back so the sliding data window can follow. The
// view model ignores everything that doesn't near a loaded edge.
LaunchedEffect(listState, continuous) {
if (!continuous) return@LaunchedEffect
// Which month the viewport is showing. Continuous reads it off the block
// whose sticky header is up; Dense has no blocks, so it takes the month the
// row below the top edge mostly sits in, which flips as that month's first
// full week comes into view.
val visibleMonth by remember(dense, weekStart) {
derivedStateOf {
if (dense) {
val midweek = weekStartForIndex(listState.firstVisibleItemIndex + 1, weekStart)
.plus(3, DateTimeUnit.DAY)
YearMonth(midweek.year, midweek.month)
} else {
yearMonthForIndex(monthIndexForItem(listState.firstVisibleItemIndex))
}
}
}
val titleMonth = if (scrolling) visibleMonth else month
// Feed the visible range back so the sliding data window can follow. Both
// styles report *months*, whatever they index by — one window serves both.
LaunchedEffect(listState, scrolling, dense, weekStart) {
if (!scrolling) 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
if (visible.isEmpty()) {
null
} else {
monthIndexForItem(visible.first().index) to
when {
visible.isEmpty() -> null
dense -> monthIndexForWeek(visible.first().index, weekStart) to
monthIndexForWeek(visible.last().index, weekStart)
else -> monthIndexForItem(visible.first().index) to
monthIndexForItem(visible.last().index)
}
}
@@ -195,10 +218,11 @@ fun MonthScreen(
val isOnCurrentMonth = titleMonth == YearMonth(today.year, today.month)
// 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.
// Continuous names the month on the block's own sticky header, so the bar
// carries the year instead of repeating it two lines further down. Dense has
// no header to defer to, so it keeps the full title.
val locale = currentLocale()
val topBarTitle = if (continuous) {
val topBarTitle = if (viewStyle == MonthViewStyle.Continuous) {
titleMonth.year.toString()
} else {
formatMonthTitle(titleMonth, locale, currentYear = today.year)
@@ -219,10 +243,11 @@ fun MonthScreen(
// (back), viewing the past → from the right (forward). The continuous stream
// scrolls to today's week instead — there is nothing to slide.
val jumpToToday = {
if (continuous) {
if (scrolling) {
scope.launch {
listState.animateScrollToItem(
itemIndexForMonth(monthIndexOf(YearMonth(today.year, today.month))),
if (dense) weekIndexOf(today, weekStart)
else itemIndexForMonth(monthIndexOf(YearMonth(today.year, today.month))),
)
}
Unit
@@ -237,10 +262,11 @@ fun MonthScreen(
}
// Drawer jump-to-date: slide from the side the target month lies on.
val jumpToDate: (LocalDate) -> Unit = { target ->
if (continuous) {
if (scrolling) {
scope.launch {
listState.animateScrollToItem(
itemIndexForMonth(monthIndexOf(YearMonth(target.year, target.month))),
if (dense) weekIndexOf(LocalDate(target.year, target.month, 1), weekStart)
else itemIndexForMonth(monthIndexOf(YearMonth(target.year, target.month))),
)
}
} else {
@@ -310,10 +336,11 @@ fun MonthScreen(
) {
WeekdayHeader(weekStart = weekStart, showWeekNumbers = showWeekNumbers)
CompositionLocalProvider(LocalDimCutoff provides dimCutoff) {
if (continuous) {
if (scrolling) {
ContinuousMonthContent(
state = continuousState,
listState = listState,
dense = dense,
showWeekNumbers = showWeekNumbers,
onRetry = jumpToToday,
onOpenDay = onOpenDay,
@@ -389,14 +416,15 @@ private fun MonthContent(
}
/**
* Continuous style content. No swipe detector and no [AnimatedContent]: vertical
* scrolling owns the gesture, and the list never swaps wholesale — it keeps
* scrolling while the window behind it reloads.
* Content for both scrolling styles. No swipe detector and no [AnimatedContent]:
* vertical scrolling owns the gesture, and the list never swaps wholesale — it
* keeps scrolling while the window behind it reloads.
*/
@Composable
private fun ContinuousMonthContent(
state: ContinuousMonthUiState,
listState: LazyListState,
dense: Boolean,
showWeekNumbers: Boolean,
onRetry: () -> Unit,
onOpenDay: (LocalDate) -> Unit,
@@ -405,12 +433,21 @@ private fun ContinuousMonthContent(
ContinuousMonthUiState.Loading -> MonthGridLoading()
is ContinuousMonthUiState.Failure ->
CalendarFailure(reason = state.reason, onRetry = onRetry)
is ContinuousMonthUiState.Success -> ContinuousMonthGrid(
is ContinuousMonthUiState.Success -> if (dense) {
DenseMonthGrid(
state = state,
listState = listState,
showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay,
)
} else {
ContinuousMonthGrid(
state = state,
listState = listState,
showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay,
)
}
}
}
@@ -648,23 +685,86 @@ private fun ContinuousMonthBlock(
/**
* 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.
* label is what makes each month read as its own section. The rule beneath it
* closes the header off against the grid; it is the one part of this layout the
* user is still deciding on.
*/
@Composable
private fun ContinuousMonthHeader(month: YearMonth, currentYear: Int, isCurrent: Boolean) {
val locale = currentLocale()
Column(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface)
.padding(top = 20.dp),
) {
Text(
text = formatMonthTitle(month, locale, currentYear),
style = MaterialTheme.typography.titleMedium,
color = if (isCurrent) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 0.dp),
)
HorizontalDivider(
color = MaterialTheme.colorScheme.outlineVariant,
modifier = Modifier.padding(top = 8.dp, bottom = 8.dp),
)
}
}
/**
* The Dense style (#38): one uninterrupted vertical stream of weeks, months
* flowing into each other.
*
* Kept alongside the block layout rather than replaced by it — the seams that
* make months legible are exactly what someone wanting a dense calendar doesn't
* want. There is no month boundary to dim across here, so every day reads as
* in-month and the 1st names its month: the only marker of where one ends.
*
* 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.
*/
@Composable
internal fun DenseMonthGrid(
state: ContinuousMonthUiState.Success,
listState: LazyListState,
showWeekNumbers: Boolean,
onOpenDay: (LocalDate) -> Unit,
modifier: Modifier = Modifier,
) {
val weekCount = remember(state.weekStart) { continuousWeekCount(state.weekStart) }
LazyColumn(
state = listState,
modifier = modifier
.fillMaxSize()
.padding(horizontal = 8.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
// Bottom inset clears the FAB stack so the last row stays tappable.
contentPadding = PaddingValues(top = 4.dp, bottom = 96.dp),
) {
items(count = weekCount, key = { it }) { index ->
val week = state.weeksByIndex[index]
if (week == null) {
ContinuousWeekPlaceholder()
} else {
MonthWeekRow(
week = week,
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,
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface)
.padding(horizontal = 12.dp)
.padding(top = 20.dp, bottom = 10.dp),
.height(CONTINUOUS_ROW_HEIGHT),
)
}
}
}
}
/**
* The month-changing horizontal swipe, shared by the paged and split styles.
@@ -970,6 +1070,7 @@ private fun MonthWeekRow(
onOpenDay: (LocalDate) -> Unit,
modifier: Modifier = Modifier,
blankOutside: Boolean = false,
labelMonthOnFirst: Boolean = false,
) {
val dark = isSystemInDarkTheme()
val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1
@@ -1029,6 +1130,13 @@ private fun MonthWeekRow(
date = d,
isToday = d == today,
inMonth = inMonth(d),
// Dense has no month boundaries to dim across and
// no header to name the month, so the 1st does it.
monthLabel = if (labelMonthOnFirst && d.day == 1) {
shortMonthName(d)
} else {
null
},
modifier = Modifier.weight(1f),
)
}
@@ -1167,6 +1275,7 @@ private fun DayNumberCell(
isToday: Boolean,
inMonth: Boolean,
modifier: Modifier = Modifier,
monthLabel: String? = null,
) {
Box(
modifier = modifier.height(DAY_NUMBER_HEIGHT),
@@ -1186,6 +1295,16 @@ 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(),
@@ -1197,6 +1316,16 @@ private fun DayNumberCell(
}
}
/** Locale-short month name ("Jul", "Juli"), for the 1st in the Dense 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(

View File

@@ -119,6 +119,18 @@ internal fun MonthStylePreview(
showWeekNumbers = false,
onOpenDay = {},
)
MonthViewStyle.Dense -> DenseMonthGrid(
state = sample.continuous,
listState = rememberLazyListState(
// Start a week above today's, so the preview shows a
// stream running past the viewport rather than one
// beginning at its top edge.
initialFirstVisibleItemIndex =
weekIndexOf(today, weekStart) - 1,
),
showWeekNumbers = false,
onOpenDay = {},
)
MonthViewStyle.Split -> {
SplitMonthGrid(
state = sample.month,
@@ -182,16 +194,23 @@ private fun sampleMonthState(
zone = zone,
)
// 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.
// The month either side of this one: enough for Continuous to show a block,
// the whitespace under it and the next month's header coming up, and for
// Dense to have somewhere to scroll in both directions.
val centre = monthIndexOf(ym)
val window = centre..(centre + 1)
val window = (centre - 1)..(centre + 1)
val continuous = ContinuousMonthUiState.Success(
today = today,
monthsByIndex = window.associateWith { index ->
val m = yearMonthForIndex(index)
layoutMonthWeeks(m, weekStart, events, zone).map { clipWeekToMonth(it, m) }
},
weeksByIndex = weekWindowFor(window, weekStart).associateWith { index ->
val days = (0 until 7).map {
weekStartForIndex(index, weekStart).plus(it, DateTimeUnit.DAY)
}
layoutCalendarWeek(days, events, zone)
},
weekStart = weekStart,
)
return SampleMonth(month, continuous)

View File

@@ -46,8 +46,17 @@ data class MonthWeek(
* 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.
* The same loaded window is served two ways, because the Dense style is the same
* scroll with the seams taken out and it would be wasteful to query the provider
* twice for one range:
*
* - [monthsByIndex] — the block layout, keyed by absolute month index, each week
* clipped to its own month.
* - [weeksByIndex] — the flowing layout, keyed by absolute week index, boundary
* weeks left carrying both months.
*
* Both hold only the loaded window; indices outside it render as placeholders
* until the window catches up.
*/
sealed interface ContinuousMonthUiState {
data object Loading : ContinuousMonthUiState
@@ -55,6 +64,7 @@ sealed interface ContinuousMonthUiState {
data class Success(
val today: LocalDate,
val monthsByIndex: Map<Int, List<MonthWeek>>,
val weeksByIndex: Map<Int, MonthWeek>,
val weekStart: DayOfWeek,
) : ContinuousMonthUiState
}

View File

@@ -30,6 +30,7 @@ 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
@@ -165,9 +166,18 @@ class MonthViewModel @Inject constructor(
val ym = yearMonthForIndex(index)
layoutMonthWeeks(ym, weekStart, instances, zone).map { clipWeekToMonth(it, ym) }
}
// The Dense style's rows, from the same query: every week the window's
// months touch, laid out whole rather than clipped to a month.
val weeks = weekWindowFor(window, weekStart).associateWith { index ->
val days = (0 until 7).map {
weekStartForIndex(index, weekStart).plus(it, DateTimeUnit.DAY)
}
layoutCalendarWeek(days, instances, zone)
}
return ContinuousMonthUiState.Success(
today = todayDate,
monthsByIndex = months,
weeksByIndex = weeks,
weekStart = weekStart,
)
}
@@ -415,6 +425,52 @@ internal fun clampMonthWindow(window: IntRange): IntRange {
internal fun firstOfMonth(ym: YearMonth): LocalDate = LocalDate(ym.year, ym.month, 1)
/**
* The Dense style addresses *weeks* by absolute index instead: it has no month
* blocks to key by, and a row's identity has to survive the months around it
* scrolling past. Index 0 is the first week of 1900 under the active week-start,
* so — like the month space — every index is non-negative and the LazyColumn's
* item indices and week indices are the same number.
*
* Unlike month indices these *do* shift with the week-start preference, which is
* why the list state is keyed on it.
*/
private val WEEK_INDEX_EPOCH = LocalDate(1900, 1, 1)
private val WEEK_INDEX_END = LocalDate(2100, 12, 31)
internal fun weekIndexOf(date: LocalDate, weekStart: DayOfWeek): Int {
val base = WEEK_INDEX_EPOCH.startOfGridWeek(weekStart)
return base.daysUntil(date.startOfGridWeek(weekStart)) / 7
}
internal fun weekStartForIndex(index: Int, weekStart: DayOfWeek): LocalDate =
WEEK_INDEX_EPOCH.startOfGridWeek(weekStart).plus(index * 7, DateTimeUnit.DAY)
/** Total weeks the Dense grid scrolls through (1900 → 2100). */
internal fun continuousWeekCount(weekStart: DayOfWeek): Int =
weekIndexOf(WEEK_INDEX_END, weekStart) + 1
/** Which month a Dense row belongs to, for reporting the visible range. */
internal fun monthIndexForWeek(weekIndex: Int, weekStart: DayOfWeek): Int {
// The row's midweek day: a boundary week is reported as whichever month owns
// most of it, the same rule the title uses.
val midweek = weekStartForIndex(weekIndex, weekStart).plus(3, DateTimeUnit.DAY)
return monthIndexOf(YearMonth(midweek.year, midweek.month))
}
/**
* Every week index the months in [window] touch — the Dense rows one month-window
* load covers. Widened by a week at each end so the boundary rows either side are
* laid out too, rather than flickering as placeholders.
*/
internal fun weekWindowFor(window: IntRange, weekStart: DayOfWeek): IntRange {
val first = weekIndexOf(firstOfMonth(yearMonthForIndex(window.first)), weekStart) - 1
val lastMonthEnd = firstOfMonth(yearMonthForIndex(window.last))
.plus(1, DateTimeUnit.MONTH)
.minus(1, DateTimeUnit.DAY)
return first..(weekIndexOf(lastMonthEnd, weekStart) + 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)

View File

@@ -19,20 +19,34 @@ enum class MonthViewStyle {
Paged,
/**
* One uninterrupted vertical stream of weeks. Each date appears exactly once,
* which is the point — paging repeats a boundary week at both ends (#38).
* Months stacked into one vertical scroll, each a self-contained block under
* its own sticky header: a block shows only its own days, and whitespace
* separates it from the next (#38).
*/
Continuous,
/**
* [Continuous] with the seams taken out: one uninterrupted stream of weeks
* where months flow into each other. Each date appears exactly once — paging
* repeats a boundary week at both ends — and the 1st names its month, the
* only marker of where one ends.
*/
Dense,
/** Compact dots-only grid over a list of the selected day's events (#53). */
Split,
}
/** Both vertically scrolling styles, which share a data window and a list state. */
val MonthViewStyle.isScrolling: Boolean
get() = this == MonthViewStyle.Continuous || this == MonthViewStyle.Dense
@get:StringRes
val MonthViewStyle.labelRes: Int
get() = when (this) {
MonthViewStyle.Paged -> R.string.month_style_paged
MonthViewStyle.Continuous -> R.string.month_style_continuous
MonthViewStyle.Dense -> R.string.month_style_dense
MonthViewStyle.Split -> R.string.month_style_split
}
@@ -41,5 +55,6 @@ val MonthViewStyle.descriptionRes: Int
get() = when (this) {
MonthViewStyle.Paged -> R.string.month_style_paged_summary
MonthViewStyle.Continuous -> R.string.month_style_continuous_summary
MonthViewStyle.Dense -> R.string.month_style_dense_summary
MonthViewStyle.Split -> R.string.month_style_split_summary
}

View File

@@ -380,6 +380,8 @@
<string name="month_style_paged_summary">One month at a time. Swipe sideways to change month.</string>
<string name="month_style_continuous">Continuous</string>
<string name="month_style_continuous_summary">Scroll up and down through the months. Each month keeps to itself under its own heading, with no days borrowed from its neighbours.</string>
<string name="month_style_dense">Dense</string>
<string name="month_style_dense_summary">The same endless scroll with the seams taken out — one unbroken run of weeks, months flowing into each other.</string>
<string name="month_style_split">Split</string>
<string name="month_style_split_summary">A compact grid with dots for events, and the day you tap listed underneath.</string>
<string name="month_split_no_events">Nothing scheduled</string>

View File

@@ -1,11 +1,14 @@
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.Month
import kotlinx.datetime.TimeZone
import kotlinx.datetime.YearMonth
import kotlinx.datetime.minus
import kotlinx.datetime.plus
import org.junit.jupiter.api.Test
/**
@@ -107,6 +110,56 @@ class ContinuousMonthIndexTest {
}
}
@Test
fun `the dense week window covers every week its months touch`() {
// One month-window load has to lay out every Dense row those months
// appear in, boundary weeks included, or rows would flicker as
// placeholders while the data for them is already in hand.
DayOfWeek.entries.forEach { ws ->
val window = monthIndexOf(jun26)..monthIndexOf(YearMonth(2026, Month.AUGUST))
val weeks = weekWindowFor(window, ws)
window.forEach { index ->
val ym = yearMonthForIndex(index)
val first = weekIndexOf(firstOfMonth(ym), ws)
val last = weekIndexOf(firstOfMonth(yearMonthForIndex(index + 1)).minus(1, DateTimeUnit.DAY), ws)
assertThat(weeks.contains(first)).isTrue()
assertThat(weeks.contains(last)).isTrue()
}
}
}
@Test
fun `a dense row reports the month that owns most of it`() {
// June 29 July 5, 2026 (Monday-anchored): four of its days are July's,
// so the window follows July rather than snapping back to June.
val boundary = weekIndexOf(LocalDate(2026, 6, 29), DayOfWeek.MONDAY)
assertThat(monthIndexForWeek(boundary, DayOfWeek.MONDAY))
.isEqualTo(monthIndexOf(YearMonth(2026, Month.JULY)))
// A row wholly inside June reports June.
assertThat(monthIndexForWeek(weekIndexOf(LocalDate(2026, 6, 15), DayOfWeek.MONDAY), DayOfWeek.MONDAY))
.isEqualTo(monthIndexOf(jun26))
}
@Test
fun `every day of a dense week shares one index`() {
val mon = LocalDate(2026, 6, 8)
val indices = (0..6).map { weekIndexOf(mon.plus(it, DateTimeUnit.DAY), DayOfWeek.MONDAY) }
assertThat(indices.toSet()).hasSize(1)
}
@Test
fun `dense week indices round-trip and stay inside the list`() {
val wed = LocalDate(2026, 6, 10)
DayOfWeek.entries.forEach { ws ->
val index = weekIndexOf(wed, ws)
assertThat(weekStartForIndex(index, ws)).isEqualTo(wed.startOfGridWeek(ws))
assertThat(weekIndexOf(LocalDate(1900, 1, 1), ws)).isAtLeast(0)
assertThat(index).isLessThan(continuousWeekCount(ws))
assertThat(weekIndexOf(LocalDate(2100, 12, 31), ws))
.isLessThan(continuousWeekCount(ws))
}
}
@Test
fun `firstOfMonth is the 1st`() {
assertThat(firstOfMonth(jun26)).isEqualTo(LocalDate(2026, 6, 1))