feat(month): month view style picker + Split pull-to-expand (#38, #53) #90

Merged
makiolaj merged 36 commits from feat/month-view-style into release/v2.16.0 2026-07-20 21:25:50 +00:00
7 changed files with 339 additions and 55 deletions
Showing only changes of commit 24b005126e - Show all commits

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,22 +685,85 @@ 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),
)
}
}
}
}
/**
@@ -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))