fix(month): dim the overflow dots of a completed day too (#79)
"Dim completed events" faded a day's bars and pills but never the dots standing for the events that didn't fit it, so a past day with four or more events kept its "+N" and its dots at full strength. Both dot renderers only ever received colours and a count, which is not enough to ask whether an event has ended. They now take the events themselves: overflowEvents() is the documented complement of laneEvents(), so the split grid's "+N" knows what it stands for, and the paged grid passes its hidden events instead of their colours. A dot covers every hidden event sharing its colour, so it dims once all of them are over; the "+N" dims once the whole overflow is.
This commit is contained in:
@@ -137,6 +137,7 @@ import kotlinx.datetime.toJavaLocalDate
|
|||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Instant
|
||||||
import java.time.format.TextStyle as JavaTextStyle
|
import java.time.format.TextStyle as JavaTextStyle
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
@@ -1277,7 +1278,7 @@ internal fun SplitMonthGrid(
|
|||||||
SplitDayCell(
|
SplitDayCell(
|
||||||
date = day,
|
date = day,
|
||||||
events = seated,
|
events = seated,
|
||||||
hidden = (week.countByDay[day] ?: 0) - seated.size,
|
hidden = week.overflowEvents(col, day, MAX_EVENT_ROWS),
|
||||||
isToday = day == state.today,
|
isToday = day == state.today,
|
||||||
// A page marks only the days its own month owns. Paging
|
// A page marks only the days its own month owns. Paging
|
||||||
// moves the selection before this month's replacement
|
// moves the selection before this month's replacement
|
||||||
@@ -1318,7 +1319,7 @@ private fun SplitDayCell(
|
|||||||
date: LocalDate,
|
date: LocalDate,
|
||||||
events: List<EventInstance>,
|
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: List<EventInstance>,
|
||||||
isToday: Boolean,
|
isToday: Boolean,
|
||||||
isSelected: Boolean,
|
isSelected: Boolean,
|
||||||
inMonth: Boolean,
|
inMonth: Boolean,
|
||||||
@@ -1436,9 +1437,15 @@ 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<EventInstance>, hidden: Int, dark: Boolean) {
|
private fun SplitDots(
|
||||||
|
date: LocalDate,
|
||||||
|
events: List<EventInstance>,
|
||||||
|
hidden: List<EventInstance>,
|
||||||
|
dark: Boolean,
|
||||||
|
) {
|
||||||
if (events.isEmpty()) return
|
if (events.isEmpty()) return
|
||||||
val soften = LocalSoftenColors.current
|
val soften = LocalSoftenColors.current
|
||||||
|
val dimCutoff = LocalDimCutoff.current
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@@ -1461,18 +1468,21 @@ private fun SplitDots(date: LocalDate, events: List<EventInstance>, hidden: Int,
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.morphBounds(MonthMorphKey.Event(date, event.instanceId))
|
.morphBounds(MonthMorphKey.Event(date, event.instanceId))
|
||||||
.size(SPLIT_DOT_SIZE)
|
.size(SPLIT_DOT_SIZE)
|
||||||
|
.alpha(if (dimCutoff != null && event.hasEnded(dimCutoff)) EventDimAlpha else 1f)
|
||||||
.background(eventFill(event.color, dark, soften), CircleShape),
|
.background(eventFill(event.color, dark, soften), CircleShape),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (hidden > 0) {
|
if (hidden.isNotEmpty()) {
|
||||||
// Tagged, not lifted: this count and the expanded grid's dot row are
|
// Tagged, not lifted: this count and the expanded grid's dot row are
|
||||||
// the same marker on the same day, so it travels with its cell like
|
// the same marker on the same day, so it travels with its cell like
|
||||||
// everything else rather than riding above the grid on its own layer.
|
// everything else rather than riding above the grid on its own layer.
|
||||||
Text(
|
Text(
|
||||||
text = "+$hidden",
|
text = "+${hidden.size}",
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.morphBounds(MonthMorphKey.Overflow(date)),
|
modifier = Modifier
|
||||||
|
.morphBounds(MonthMorphKey.Overflow(date))
|
||||||
|
.alpha(if (allEnded(hidden, dimCutoff)) EventDimAlpha else 1f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1870,15 +1880,15 @@ private fun MonthWeekRow(
|
|||||||
}
|
}
|
||||||
val hidden = (week.countByDay[d] ?: 0) - occupied.size - pillsShown.size
|
val hidden = (week.countByDay[d] ?: 0) - occupied.size - pillsShown.size
|
||||||
if (hidden > 0) {
|
if (hidden > 0) {
|
||||||
val hiddenColors = buildList {
|
val hiddenEvents = buildList {
|
||||||
week.spans
|
week.spans
|
||||||
.filter { it.lane >= shownLanes && col in it.startCol..it.endCol }
|
.filter { it.lane >= shownLanes && col in it.startCol..it.endCol }
|
||||||
.forEach { add(it.event.color) }
|
.forEach { add(it.event) }
|
||||||
timed.drop(pillsShown.size).forEach { add(it.color) }
|
addAll(timed.drop(pillsShown.size))
|
||||||
}.distinct().take(3)
|
}
|
||||||
OverflowDots(
|
OverflowDots(
|
||||||
colors = hiddenColors,
|
events = hiddenEvents,
|
||||||
extra = hidden - hiddenColors.size,
|
total = hidden,
|
||||||
dark = dark,
|
dark = dark,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.offset(x = colW * col, y = EVENT_ROW_HEIGHT * MAX_EVENT_ROWS)
|
.offset(x = colW * col, y = EVENT_ROW_HEIGHT * MAX_EVENT_ROWS)
|
||||||
@@ -2058,37 +2068,52 @@ private fun MonthBar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Overflow row: a dot per hidden event (up to three) plus "+N" for the rest. */
|
/**
|
||||||
|
* Overflow row: a dot per hidden colour (up to three) plus "+N" for the rest.
|
||||||
|
*
|
||||||
|
* A dot stands for every hidden event sharing its colour, so it dims only once
|
||||||
|
* all of them have ended; the "+N" dims once the whole overflow has (#79).
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun OverflowDots(
|
private fun OverflowDots(
|
||||||
colors: List<Int>,
|
events: List<EventInstance>,
|
||||||
extra: Int,
|
total: Int,
|
||||||
dark: Boolean,
|
dark: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val soften = LocalSoftenColors.current
|
val soften = LocalSoftenColors.current
|
||||||
|
val dimCutoff = LocalDimCutoff.current
|
||||||
|
val byColor = events.groupBy { it.color }
|
||||||
|
val dots = byColor.keys.take(3)
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier.height(EVENT_ROW_HEIGHT),
|
modifier = modifier.height(EVENT_ROW_HEIGHT),
|
||||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
colors.forEach { argb ->
|
dots.forEach { argb ->
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(6.dp)
|
.size(6.dp)
|
||||||
|
.alpha(if (allEnded(byColor.getValue(argb), dimCutoff)) EventDimAlpha else 1f)
|
||||||
.background(eventFill(argb, dark, soften), CircleShape),
|
.background(eventFill(argb, dark, soften), CircleShape),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
val extra = total - dots.size
|
||||||
if (extra > 0) {
|
if (extra > 0) {
|
||||||
Text(
|
Text(
|
||||||
text = "+$extra",
|
text = "+$extra",
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.alpha(if (allEnded(events, dimCutoff)) EventDimAlpha else 1f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True when dimming is on and every one of [events] is already over. */
|
||||||
|
private fun allEnded(events: List<EventInstance>, dimCutoff: Instant?): Boolean =
|
||||||
|
dimCutoff != null && events.isNotEmpty() && events.all { it.hasEnded(dimCutoff) }
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MonthGridLoading() {
|
private fun MonthGridLoading() {
|
||||||
val shape = MaterialTheme.shapes.medium
|
val shape = MaterialTheme.shapes.medium
|
||||||
|
|||||||
@@ -67,6 +67,24 @@ fun MonthWeek.laneEvents(col: Int, day: LocalDate, laneCap: Int): List<EventInst
|
|||||||
return byLane.filterNotNull()
|
return byLane.filterNotNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The events on [day] that [laneEvents] had no lane left for — the exact
|
||||||
|
* complement of what it seats, in the same bars-then-pills order. Together the
|
||||||
|
* two partition the day, so their sizes add up to [countByDay].
|
||||||
|
*
|
||||||
|
* The "+N" marker needs the events themselves, not just how many there are:
|
||||||
|
* dimming a completed event is a per-event question (#79).
|
||||||
|
*/
|
||||||
|
fun MonthWeek.overflowEvents(col: Int, day: LocalDate, laneCap: Int): List<EventInstance> {
|
||||||
|
val seatedLanes = spans.count { it.lane < laneCap && col in it.startCol..it.endCol }
|
||||||
|
return buildList {
|
||||||
|
spans.forEach { span ->
|
||||||
|
if (span.lane >= laneCap && col in span.startCol..span.endCol) add(span.event)
|
||||||
|
}
|
||||||
|
addAll(timedByDay[day].orEmpty().drop(laneCap - seatedLanes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* State for the continuous style (#38): a vertical stream of *self-contained*
|
* 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
|
* months rather than one undifferentiated run of weeks. Each month is keyed by
|
||||||
|
|||||||
@@ -122,6 +122,42 @@ class LaneEventsTest {
|
|||||||
.containsNoneIn(seated)
|
.containsNoneIn(seated)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `overflow is exactly what seating left behind`() {
|
||||||
|
val events = (0 until 5).map { timed(LocalDate(2026, 7, 6), hour = 8 + it, id = it + 1L) }
|
||||||
|
val week = rowOfJuly6(events)
|
||||||
|
val day = LocalDate(2026, 7, 6)
|
||||||
|
|
||||||
|
val seated = week.laneEvents(col = 0, day = day, laneCap = 3)
|
||||||
|
val overflow = week.overflowEvents(col = 0, day = day, laneCap = 3)
|
||||||
|
|
||||||
|
assertThat(overflow).containsExactlyElementsIn(events.drop(3)).inOrder()
|
||||||
|
assertThat(seated + overflow).containsExactlyElementsIn(events)
|
||||||
|
assertThat(seated.size + overflow.size).isEqualTo(week.countByDay[day])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a bar beyond the cap overflows on every day it covers`() {
|
||||||
|
val bars = (0 until 4).map {
|
||||||
|
allDay(LocalDate(2026, 7, 6), LocalDate(2026, 7, 8), id = it + 1L)
|
||||||
|
}
|
||||||
|
val week = rowOfJuly6(bars)
|
||||||
|
val parked = week.spans.filter { it.lane >= 3 }.map { it.event }
|
||||||
|
|
||||||
|
(0..2).forEach { col ->
|
||||||
|
val day = LocalDate(2026, 7, 6 + col)
|
||||||
|
assertThat(week.overflowEvents(col, day, laneCap = 3))
|
||||||
|
.containsExactlyElementsIn(parked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a day that fits has no overflow`() {
|
||||||
|
val week = rowOfJuly6(listOf(timed(LocalDate(2026, 7, 6), hour = 9, id = 1L)))
|
||||||
|
|
||||||
|
assertThat(week.overflowEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3)).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val BLUE = 0xFF3366CC.toInt()
|
const val BLUE = 0xFF3366CC.toInt()
|
||||||
const val RED = 0xFFCC3333.toInt()
|
const val RED = 0xFFCC3333.toInt()
|
||||||
|
|||||||
Reference in New Issue
Block a user