fix(month): give every covered day's dot a place inside the bar (#53)
A multi-day event has a dot on every day it covers but only one bar, so the dots away from its start had no counterpart. Untagged they stood still and faded; tagged without a partner they flew in from the top of the grid. Neither is an animation — the last commit swapped one for the other. The expanded grid now places an invisible slice of the bar in each further column it spans, keyed to that column's day. Every dot has a real place to come out of and go back into, at its own column, so it drops out of the bar above it instead of appearing from nowhere. That makes the seating's anchored flag pointless again — every dot has a partner now — so it and its tests come back out. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1287,7 +1287,7 @@ internal fun SplitMonthGrid(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun SplitDayCell(
|
private fun SplitDayCell(
|
||||||
date: LocalDate,
|
date: LocalDate,
|
||||||
events: List<SeatedEvent>,
|
events: List<EventInstance>,
|
||||||
/** Events on this day that didn't fit a lane, shown as a "+N" beside the dots. */
|
/** Events on this day that didn't fit a lane, shown as a "+N" beside the dots. */
|
||||||
hidden: Int,
|
hidden: Int,
|
||||||
isToday: Boolean,
|
isToday: Boolean,
|
||||||
@@ -1398,14 +1398,14 @@ private fun SplitDayCell(
|
|||||||
* bar with no dot to grow out of.
|
* bar with no dot to grow out of.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun SplitDots(date: LocalDate, events: List<SeatedEvent>, hidden: Int, dark: Boolean) {
|
private fun SplitDots(date: LocalDate, events: List<EventInstance>, hidden: Int, dark: Boolean) {
|
||||||
if (events.isEmpty()) return
|
if (events.isEmpty()) return
|
||||||
val soften = LocalSoftenColors.current
|
val soften = LocalSoftenColors.current
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
events.forEach { seated ->
|
events.forEach { event ->
|
||||||
// The dot keeps its own circle and the bar its 4dp corners; they
|
// The dot keeps its own circle and the bar its 4dp corners; they
|
||||||
// cross-fade inside shared bounds, and at the dot's size the two
|
// cross-fade inside shared bounds, and at the dot's size the two
|
||||||
// radii are a fraction of a pixel apart.
|
// radii are a fraction of a pixel apart.
|
||||||
@@ -1416,23 +1416,14 @@ private fun SplitDots(date: LocalDate, events: List<SeatedEvent>, hidden: Int, d
|
|||||||
// only snapped to full width once the transition ended — the growth
|
// only snapped to full width once the transition ended — the growth
|
||||||
// never happened, it was clamped away.
|
// never happened, it was clamped away.
|
||||||
//
|
//
|
||||||
// Only tagged where a bar actually meets it. A dot on the middle of a
|
// *Every* dot is tagged, including those in the middle of a multi-day
|
||||||
// multi-day run has nothing to travel to, and tagging it regardless
|
// run: the expanded grid gives each covered column its own anchor
|
||||||
// gave it no bounds to start from, so it flew in from the top of the
|
// inside the bar, so each one has a real place to come from.
|
||||||
// grid rather than fading where it stands.
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.then(
|
.morphBounds(MonthMorphKey.Event(date, event.instanceId))
|
||||||
if (seated.anchored) {
|
|
||||||
Modifier.morphBounds(
|
|
||||||
MonthMorphKey.Event(date, seated.event.instanceId),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Modifier
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.size(SPLIT_DOT_SIZE)
|
.size(SPLIT_DOT_SIZE)
|
||||||
.background(eventFill(seated.event.color, dark, soften), CircleShape),
|
.background(eventFill(event.color, dark, soften), CircleShape),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (hidden > 0) {
|
if (hidden > 0) {
|
||||||
@@ -1777,6 +1768,31 @@ private fun MonthWeekRow(
|
|||||||
.height(EVENT_ROW_HEIGHT)
|
.height(EVENT_ROW_HEIGHT)
|
||||||
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp),
|
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp),
|
||||||
)
|
)
|
||||||
|
// One invisible slice of the bar per further column it
|
||||||
|
// covers. A multi-day event has a dot on every day but
|
||||||
|
// only one bar, so those other dots had nothing to travel
|
||||||
|
// to and sat still while the rest of the grid moved.
|
||||||
|
//
|
||||||
|
// These give each of them a real place inside the bar to
|
||||||
|
// come out of and go back into, at its own column — so a
|
||||||
|
// dot drops out of the bar above it rather than appearing
|
||||||
|
// from nowhere, or from the top of the grid, which is what
|
||||||
|
// an untagged dot and a partnerless tag respectively did.
|
||||||
|
for (c in (span.startCol + 1)..span.endCol) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.offset(x = colW * c, y = EVENT_ROW_HEIGHT * span.lane)
|
||||||
|
.morphBounds(
|
||||||
|
MonthMorphKey.Event(
|
||||||
|
date = week.days[c],
|
||||||
|
instanceId = span.event.instanceId,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.width(colW)
|
||||||
|
.height(EVENT_ROW_HEIGHT)
|
||||||
|
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Single-day timed pills + overflow, per column. Pills fill the
|
// Single-day timed pills + overflow, per column. Pills fill the
|
||||||
// lane slots no bar occupies on THIS day (top-most first), so a
|
// lane slots no bar occupies on THIS day (top-most first), so a
|
||||||
|
|||||||
@@ -39,19 +39,6 @@ data class MonthWeek(
|
|||||||
val countByDay: Map<LocalDate, Int>,
|
val countByDay: Map<LocalDate, Int>,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
|
||||||
* One event seated in a lane of one day, and whether *this* day is where the
|
|
||||||
* expanded grid draws it.
|
|
||||||
*
|
|
||||||
* A multi-day event has a dot on every day it covers but only one bar, drawn from
|
|
||||||
* where it starts in that week. So only the start day's dot has something to
|
|
||||||
* become; the rest are [anchored] = false and must not be tagged for the morph at
|
|
||||||
* all. Tagging them anyway gives Compose a shared element that enters with no
|
|
||||||
* partner and therefore no bounds to start from, and it flies in from the layout
|
|
||||||
* origin — the top of the grid — instead of quietly fading where it stands (#53).
|
|
||||||
*/
|
|
||||||
data class SeatedEvent(val event: EventInstance, val anchored: Boolean)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The events occupying each lane of [day] — column [col] of this week — in lane
|
* The events occupying each lane of [day] — column [col] of this week — in lane
|
||||||
* order, capped at [laneCap] lanes.
|
* order, capped at [laneCap] lanes.
|
||||||
@@ -66,19 +53,16 @@ data class SeatedEvent(val event: EventInstance, val anchored: Boolean)
|
|||||||
* left dot _i_ standing for no particular event, and quietly merged two events
|
* left dot _i_ standing for no particular event, and quietly merged two events
|
||||||
* that shared a calendar into a single dot.
|
* that shared a calendar into a single dot.
|
||||||
*/
|
*/
|
||||||
fun MonthWeek.laneEvents(col: Int, day: LocalDate, laneCap: Int): List<SeatedEvent> {
|
fun MonthWeek.laneEvents(col: Int, day: LocalDate, laneCap: Int): List<EventInstance> {
|
||||||
val byLane = arrayOfNulls<SeatedEvent>(laneCap)
|
val byLane = arrayOfNulls<EventInstance>(laneCap)
|
||||||
spans.forEach { span ->
|
spans.forEach { span ->
|
||||||
if (span.lane < laneCap && col in span.startCol..span.endCol) {
|
if (span.lane < laneCap && col in span.startCol..span.endCol) {
|
||||||
// A bar is drawn once per row, from the column it starts in — so that
|
byLane[span.lane] = span.event
|
||||||
// is the only day of it whose dot has a counterpart to travel to.
|
|
||||||
byLane[span.lane] = SeatedEvent(span.event, anchored = col == span.startCol)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val free = (0 until laneCap).filter { byLane[it] == null }
|
val free = (0 until laneCap).filter { byLane[it] == null }
|
||||||
// A single-day event is drawn on its own day, so it is always anchored there.
|
|
||||||
timedByDay[day].orEmpty().take(free.size).forEachIndexed { i, event ->
|
timedByDay[day].orEmpty().take(free.size).forEachIndexed { i, event ->
|
||||||
byLane[free[i]] = SeatedEvent(event, anchored = true)
|
byLane[free[i]] = event
|
||||||
}
|
}
|
||||||
return byLane.filterNotNull()
|
return byLane.filterNotNull()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ class LaneEventsTest {
|
|||||||
val week = rowOfJuly6(listOf(bar, meeting))
|
val week = rowOfJuly6(listOf(bar, meeting))
|
||||||
|
|
||||||
// Jul 7 is column 1 of a Monday-anchored row starting Jul 6.
|
// 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).map { it.event })
|
assertThat(week.laneEvents(col = 1, day = LocalDate(2026, 7, 7), laneCap = 3))
|
||||||
.containsExactly(bar, meeting)
|
.containsExactly(bar, meeting)
|
||||||
.inOrder()
|
.inOrder()
|
||||||
}
|
}
|
||||||
@@ -69,7 +69,7 @@ class LaneEventsTest {
|
|||||||
val monday = timed(LocalDate(2026, 7, 6), hour = 9, id = 2L)
|
val monday = timed(LocalDate(2026, 7, 6), hour = 9, id = 2L)
|
||||||
val week = rowOfJuly6(listOf(bar, monday))
|
val week = rowOfJuly6(listOf(bar, monday))
|
||||||
|
|
||||||
assertThat(week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3).map { it.event })
|
assertThat(week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3))
|
||||||
.containsExactly(monday)
|
.containsExactly(monday)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,48 +80,11 @@ class LaneEventsTest {
|
|||||||
|
|
||||||
(1..3).forEach { col ->
|
(1..3).forEach { col ->
|
||||||
val day = LocalDate(2026, 7, 6 + col)
|
val day = LocalDate(2026, 7, 6 + col)
|
||||||
assertThat(week.laneEvents(col, day, laneCap = 3).map { it.event }).containsExactly(bar)
|
assertThat(week.laneEvents(col, day, laneCap = 3)).containsExactly(bar)
|
||||||
}
|
}
|
||||||
assertThat(week.laneEvents(col = 4, day = LocalDate(2026, 7, 10), laneCap = 3)).isEmpty()
|
assertThat(week.laneEvents(col = 4, day = LocalDate(2026, 7, 10), laneCap = 3)).isEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Only the day a bar is drawn from has a counterpart to morph into. Tagging
|
|
||||||
* the other days' dots too gave them a shared element with no partner and so
|
|
||||||
* no bounds to start from, and they flew in from the top of the grid.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
fun `only the day a multi-day bar starts on is anchored`() {
|
|
||||||
val bar = allDay(LocalDate(2026, 7, 7), LocalDate(2026, 7, 9), id = 1L)
|
|
||||||
val week = rowOfJuly6(listOf(bar))
|
|
||||||
|
|
||||||
val anchored = (1..3).map { col ->
|
|
||||||
week.laneEvents(col, LocalDate(2026, 7, 6 + col), laneCap = 3).single().anchored
|
|
||||||
}
|
|
||||||
assertThat(anchored).containsExactly(true, false, false).inOrder()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A bar carried in from the previous week restarts at column 0 of this row. */
|
|
||||||
@Test
|
|
||||||
fun `a bar continuing into the row anchors on its first day here`() {
|
|
||||||
val bar = allDay(LocalDate(2026, 7, 3), LocalDate(2026, 7, 8), id = 1L)
|
|
||||||
val week = rowOfJuly6(listOf(bar))
|
|
||||||
|
|
||||||
assertThat(week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3).single().anchored)
|
|
||||||
.isTrue()
|
|
||||||
assertThat(week.laneEvents(col = 1, day = LocalDate(2026, 7, 7), laneCap = 3).single().anchored)
|
|
||||||
.isFalse()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a single-day event is always anchored on its own day`() {
|
|
||||||
val meeting = timed(LocalDate(2026, 7, 6), hour = 9, id = 1L)
|
|
||||||
val week = rowOfJuly6(listOf(meeting))
|
|
||||||
|
|
||||||
assertThat(week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3).single().anchored)
|
|
||||||
.isTrue()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The bug the colour-gathered dots had: one dot for two events on one calendar. */
|
/** The bug the colour-gathered dots had: one dot for two events on one calendar. */
|
||||||
@Test
|
@Test
|
||||||
fun `events sharing a colour each keep their own lane`() {
|
fun `events sharing a colour each keep their own lane`() {
|
||||||
@@ -129,7 +92,7 @@ class LaneEventsTest {
|
|||||||
val second = timed(LocalDate(2026, 7, 6), hour = 14, id = 2L, color = RED)
|
val second = timed(LocalDate(2026, 7, 6), hour = 14, id = 2L, color = RED)
|
||||||
val week = rowOfJuly6(listOf(first, second))
|
val week = rowOfJuly6(listOf(first, second))
|
||||||
|
|
||||||
assertThat(week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3).map { it.event })
|
assertThat(week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3))
|
||||||
.containsExactly(first, second)
|
.containsExactly(first, second)
|
||||||
.inOrder()
|
.inOrder()
|
||||||
}
|
}
|
||||||
@@ -141,7 +104,7 @@ class LaneEventsTest {
|
|||||||
|
|
||||||
val seated = week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3)
|
val seated = week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3)
|
||||||
assertThat(seated).hasSize(3)
|
assertThat(seated).hasSize(3)
|
||||||
assertThat(seated.map { it.event }).containsExactlyElementsIn(events.take(3)).inOrder()
|
assertThat(seated).containsExactlyElementsIn(events.take(3)).inOrder()
|
||||||
assertThat(week.countByDay[LocalDate(2026, 7, 6)]!! - seated.size).isEqualTo(2)
|
assertThat(week.countByDay[LocalDate(2026, 7, 6)]!! - seated.size).isEqualTo(2)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,7 +119,7 @@ class LaneEventsTest {
|
|||||||
val seated = week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3)
|
val seated = week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3)
|
||||||
assertThat(seated).hasSize(3)
|
assertThat(seated).hasSize(3)
|
||||||
assertThat(week.spans.filter { it.lane >= 3 }.map { it.event })
|
assertThat(week.spans.filter { it.lane >= 3 }.map { it.event })
|
||||||
.containsNoneIn(seated.map { it.event })
|
.containsNoneIn(seated)
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
|
|||||||
Reference in New Issue
Block a user