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() + } +}