From 1499b5803d7ce62cc478b4b5a3fdde5434da6e5c Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 20 Jul 2026 19:26:57 +0200 Subject: [PATCH] fix(month): seat the split style's dots by lane (#53) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dots gathered a day's *distinct colours*, which meant dot i stood for no particular event and two events sharing a calendar collapsed into one — undercounting the day. Seat them off the same lane assignment the paged grid draws from, so a dot and the bar in that lane are the same event. That fixes the count, and it is what will let one morph into the other when the grid expands. The cap is now MAX_EVENT_ROWS rather than a constant of its own: the two have to agree or a dot would have no bar to become. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/month/MonthScreen.kt | 39 ++++-- .../calendula/ui/month/MonthUiState.kt | 28 ++++ .../calendula/ui/month/LaneEventsTest.kt | 129 ++++++++++++++++++ 3 files changed, 183 insertions(+), 13 deletions(-) create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/ui/month/LaneEventsTest.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index ffde964..b9f70dd 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -902,7 +902,9 @@ private fun SplitMonthContent( */ private val SPLIT_ROW_HEIGHT = 46.dp private val SPLIT_DOT_SIZE = 5.dp -private const val SPLIT_MAX_DOTS = 3 +// Dots are capped by MAX_EVENT_ROWS, not a constant of their own: they stand for +// the paged grid's lanes, so the two caps have to be the same number or a dot +// would have no bar to become (#53). /** * Rows the split grid always reserves — the most any month needs. A month that @@ -945,11 +947,15 @@ internal fun SplitMonthGrid( .fillMaxHeight(), ) } - week.days.forEach { day -> + week.days.forEachIndexed { col, day -> val inMonth = day.month == month.month && day.year == month.year + // Seated by lane rather than gathered by colour, so each dot + // is the event the expanded grid draws in that same lane. + val seated = week.laneEvents(col, day, MAX_EVENT_ROWS) SplitDayCell( date = day, - events = state.instancesByDay[day].orEmpty(), + events = seated, + hidden = (week.countByDay[day] ?: 0) - seated.size, isToday = day == state.today, // A page marks only the days its own month owns. Paging // moves the selection before this month's replacement @@ -979,7 +985,7 @@ internal fun SplitMonthGrid( } /** - * One compact day: its number over up to [SPLIT_MAX_DOTS] event dots. + * One compact day: its number over up to [MAX_EVENT_ROWS] lane-seated event dots. * * Selection and today are deliberately different signals — a tinted, outlined * cell versus the filled circle the other views already use for today — so the @@ -989,6 +995,8 @@ internal fun SplitMonthGrid( private fun SplitDayCell( date: LocalDate, events: List, + /** Events on this day that didn't fit a lane, shown as a "+N" beside the dots. */ + hidden: Int, isToday: Boolean, isSelected: Boolean, inMonth: Boolean, @@ -1037,32 +1045,37 @@ private fun SplitDayCell( modifier = Modifier.fillMaxWidth(), ) Spacer(Modifier.height(2.dp)) - SplitDots(events = events, dark = dark) + SplitDots(events = events, hidden = hidden, dark = dark) } } } -/** Up to three colour dots for a day, plus a count when more are hidden. */ +/** + * One dot per seated event, in lane order, plus a "+N" for those that didn't fit. + * + * Deliberately *not* de-duplicated by colour: [events] arrives lane-seated so the + * dots line up with the bars the expanded grid draws, and collapsing two events + * that share a calendar into one dot would both undercount the day and leave a + * bar with no dot to grow out of. + */ @Composable -private fun SplitDots(events: List, dark: Boolean) { +private fun SplitDots(events: List, hidden: Int, dark: Boolean) { if (events.isEmpty()) return val soften = LocalSoftenColors.current - val colors = remember(events) { events.map { it.color }.distinct().take(SPLIT_MAX_DOTS) } - val extra = events.size - colors.size Row( horizontalArrangement = Arrangement.spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically, ) { - colors.forEach { argb -> + events.forEach { event -> Box( modifier = Modifier .size(SPLIT_DOT_SIZE) - .background(eventFill(argb, dark, soften), CircleShape), + .background(eventFill(event.color, dark, soften), CircleShape), ) } - if (extra > 0) { + if (hidden > 0) { Text( - text = "+$extra", + text = "+$hidden", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt index 564466b..3b1bc80 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt @@ -39,6 +39,34 @@ data class MonthWeek( val countByDay: Map, ) +/** + * The events occupying each lane of [day] — column [col] of this week — in lane + * order, capped at [laneCap] lanes. + * + * This is the same seating [spans]/[timedByDay] get when the paged grid draws a + * week: bars keep the lane the row layout gave them, and the day's timed events + * fill whatever slots are left, top-most first. Reading it here means the split + * style's dots and the paged style's bars describe a day in the *same order*, so + * dot _i_ and lane _i_ are the same event and one can morph into the other (#53). + * + * Deriving the dots independently — by distinct colour, as they first were — + * left dot _i_ standing for no particular event, and quietly merged two events + * that shared a calendar into a single dot. + */ +fun MonthWeek.laneEvents(col: Int, day: LocalDate, laneCap: Int): List { + val byLane = arrayOfNulls(laneCap) + spans.forEach { span -> + if (span.lane < laneCap && col in span.startCol..span.endCol) { + byLane[span.lane] = span.event + } + } + val free = (0 until laneCap).filter { byLane[it] == null } + timedByDay[day].orEmpty().take(free.size).forEachIndexed { i, event -> + byLane[free[i]] = event + } + return byLane.filterNotNull() +} + /** * State for the continuous style (#38): a vertical stream of *self-contained* * months rather than one undifferentiated run of weeks. Each month is keyed by diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/LaneEventsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/LaneEventsTest.kt new file mode 100644 index 0000000..0374ab3 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/LaneEventsTest.kt @@ -0,0 +1,129 @@ +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 lets the split style's dots morph into the paged style's bars (#53): both + * read a day off the *same* lane seating, so dot _i_ and lane _i_ are one event. + */ +class LaneEventsTest { + + private val zone = TimeZone.UTC + private val jul26 = YearMonth(2026, Month.JULY) + + /** July 2026 starts on a Wednesday, so this row — Jul 6–12 — sits wholly inside it. */ + private fun rowOfJuly6(events: List) = + layoutMonthWeeks(jul26, DayOfWeek.MONDAY, events, zone)[1] + + private fun allDay(from: LocalDate, toInclusive: LocalDate, id: Long, color: Int = BLUE) = + EventInstance( + instanceId = id, + eventId = id, + calendarId = 1L, + title = "A$id", + start = from.atTime(0, 0).toInstant(zone), + end = toInclusive.plus(1, DateTimeUnit.DAY).atTime(0, 0).toInstant(zone), + isAllDay = true, + color = color, + location = null, + ) + + private fun timed(date: LocalDate, hour: Int, id: Long, color: Int = RED) = EventInstance( + instanceId = id, + eventId = id, + calendarId = 1L, + title = "T$id", + start = date.atTime(hour, 0).toInstant(zone), + end = date.atTime(hour + 1, 0).toInstant(zone), + isAllDay = false, + color = color, + location = null, + ) + + @Test + fun `a bar keeps its lane and the day's timed events fill what's left`() { + val bar = allDay(LocalDate(2026, 7, 7), LocalDate(2026, 7, 9), id = 1L) + val meeting = timed(LocalDate(2026, 7, 7), hour = 9, id = 2L) + val week = rowOfJuly6(listOf(bar, meeting)) + + // Jul 7 is column 1 of a Monday-anchored row starting Jul 6. + assertThat(week.laneEvents(col = 1, day = LocalDate(2026, 7, 7), laneCap = 3)) + .containsExactly(bar, meeting) + .inOrder() + } + + @Test + fun `a day the bar misses seats its own events from lane zero`() { + val bar = allDay(LocalDate(2026, 7, 7), LocalDate(2026, 7, 9), id = 1L) + val monday = timed(LocalDate(2026, 7, 6), hour = 9, id = 2L) + val week = rowOfJuly6(listOf(bar, monday)) + + assertThat(week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3)) + .containsExactly(monday) + } + + @Test + fun `a multi-day bar is seated on every day it covers`() { + val bar = allDay(LocalDate(2026, 7, 7), LocalDate(2026, 7, 9), id = 1L) + val week = rowOfJuly6(listOf(bar)) + + (1..3).forEach { col -> + val day = LocalDate(2026, 7, 6 + col) + assertThat(week.laneEvents(col, day, laneCap = 3)).containsExactly(bar) + } + assertThat(week.laneEvents(col = 4, day = LocalDate(2026, 7, 10), laneCap = 3)).isEmpty() + } + + /** The bug the colour-gathered dots had: one dot for two events on one calendar. */ + @Test + fun `events sharing a colour each keep their own lane`() { + val first = timed(LocalDate(2026, 7, 6), hour = 9, id = 1L, color = RED) + val second = timed(LocalDate(2026, 7, 6), hour = 14, id = 2L, color = RED) + val week = rowOfJuly6(listOf(first, second)) + + assertThat(week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3)) + .containsExactly(first, second) + .inOrder() + } + + @Test + fun `seating stops at the cap and leaves the rest to the overflow count`() { + val events = (0 until 5).map { timed(LocalDate(2026, 7, 6), hour = 8 + it, id = it + 1L) } + val week = rowOfJuly6(events) + + val seated = week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3) + assertThat(seated).hasSize(3) + assertThat(seated).containsExactlyElementsIn(events.take(3)).inOrder() + assertThat(week.countByDay[LocalDate(2026, 7, 6)]!! - seated.size).isEqualTo(2) + } + + /** A bar parked below the cap is out of view, so it takes no dot with it. */ + @Test + fun `a bar beyond the cap is left out`() { + val bars = (0 until 4).map { + allDay(LocalDate(2026, 7, 6), LocalDate(2026, 7, 8), id = it + 1L) + } + val week = rowOfJuly6(bars) + + val seated = week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3) + assertThat(seated).hasSize(3) + assertThat(week.spans.filter { it.lane >= 3 }.map { it.event }) + .containsNoneIn(seated) + } + + private companion object { + const val BLUE = 0xFF3366CC.toInt() + const val RED = 0xFFCC3333.toInt() + } +}