refactor(month): make the continuous style a stack of month blocks (#38)

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) <noreply@anthropic.com>
This commit is contained in:
2026-07-20 17:54:26 +02:00
parent 94a82c2f6c
commit 6e3d10858f
8 changed files with 551 additions and 294 deletions

View File

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

View File

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

View File

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