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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user