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

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