From c27a645c194f481d67c4a39126b0e60e7d9c1625 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 15 Jun 2026 22:29:38 +0200 Subject: [PATCH] feat(month): show real events with continuous multi-day bars Replace the per-day dot summary with an event-rich grid. The ViewModel now splits the grid into week rows and, per row, resolves all-day/multi-day events into spanning bars (reusing the week view's layoutAllDay lane math) and single-day timed events into per-day pills. The grid renders as an overlay: each day gets a rounded surfaceContainer background (matching the week/day views), spanning bars draw on top so a multi-day event is one connected bar bridging the cells it covers, and single-day pills fill the lane slots no bar occupies on that specific day (top-most first) so a bar-free day isn't pushed down. Up to three rows show per day, then a "+N" dot row. Today is a filled circle on its number; neighbour-month days are dimmed. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 7 + .../calendula/ui/month/MonthScreen.kt | 355 ++++++++++++------ .../calendula/ui/month/MonthUiState.kt | 40 +- .../calendula/ui/month/MonthViewModel.kt | 59 ++- 4 files changed, 333 insertions(+), 128 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a881f24..23bc8b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- The month view now shows real events in each day instead of coloured + dots: all-day and multi-day events render as continuous bars at the top + (a multi-day event is one connected bar across the days it spans, not a + chip per day), with single-day timed events as filled pills beneath. + Up to three rows show per day, then a "+N" dot indicator for the rest. + Each day keeps a rounded surface background, matching the week and day + views; today is marked with a filled circle on its number - The slide-out panel now has a "View" section to switch between Month, Week, and Day, mirroring the top-bar switcher pill — tapping a view selects it and closes the drawer. The current view is highlighted 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 432cf08..ec10559 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 @@ -1,30 +1,30 @@ package de.jeanlucmakiola.calendula.ui.month import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectHorizontalDragGestures -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Menu -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.DrawerValue import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -43,7 +43,9 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity @@ -52,6 +54,7 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -67,12 +70,10 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.calendula.ui.common.pastelize import kotlinx.coroutines.launch -import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DayOfWeek import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone import kotlinx.datetime.YearMonth -import kotlinx.datetime.plus import kotlinx.datetime.toLocalDateTime import kotlin.time.Clock import java.time.format.TextStyle as JavaTextStyle @@ -178,7 +179,6 @@ fun MonthScreen( WeekdayHeader(weekStart = weekStart) MonthContent( state = state, - weekStart = weekStart, slideDir = slideDir, onSwipeNext = goNext, onSwipePrev = goPrev, @@ -193,7 +193,6 @@ fun MonthScreen( @Composable private fun MonthContent( state: MonthUiState, - weekStart: DayOfWeek, slideDir: Int, onSwipeNext: () -> Unit, onSwipePrev: () -> Unit, @@ -238,7 +237,6 @@ private fun MonthContent( is MonthUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry) is MonthUiState.Success -> MonthGrid( state = s, - weekStart = weekStart, onOpenDay = onOpenDay, ) } @@ -308,140 +306,279 @@ private fun WeekdayHeader(weekStart: DayOfWeek) { } } +private val EVENT_ROW_HEIGHT = 20.dp +private val DAY_NUMBER_HEIGHT = 22.dp +private val DAY_NUMBER_GAP = 4.dp +private val CELL_TOP_PADDING = 6.dp +private val CELL_GAP = 2.dp +private val CELL_SHAPE = RoundedCornerShape(12.dp) +private const val MAX_EVENT_ROWS = 3 + @Composable private fun MonthGrid( state: MonthUiState.Success, - weekStart: DayOfWeek, onOpenDay: (LocalDate) -> Unit, ) { - val firstOfMonth = LocalDate(state.month.year, state.month.month, 1) - val gridStart = firstOfMonth.startOfGridWeek(weekStart) - - // Show only the weeks the current month actually touches; leading/trailing - // days of neighbouring months are left blank rather than rendered. - val leadOffset = ((firstOfMonth.dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7 - val daysInMonth = - java.time.YearMonth.of(state.month.year, state.month.month.ordinal + 1).lengthOfMonth() - val weeks = (leadOffset + daysInMonth + 6) / 7 - Column( modifier = Modifier .fillMaxSize() - .padding(horizontal = 8.dp, vertical = 4.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), + .padding(horizontal = 4.dp, vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), ) { - repeat(weeks) { row -> - Row( + state.weeks.forEach { week -> + MonthWeekRow( + week = week, + today = state.today, + month = state.month, + onOpenDay = onOpenDay, modifier = Modifier .fillMaxWidth() .weight(1f), - horizontalArrangement = Arrangement.spacedBy(4.dp), + ) + } + } +} + +/** + * One week of the grid. Bars (all-day / multi-day) are positioned absolutely so + * a multi-day event is one connected bar across the columns; single-day timed + * events sit beneath them as filled pills in their own cell. The cap is + * [MAX_EVENT_ROWS] rows of bars+pills, then a "+N" dot indicator per day. + * A transparent per-day layer on top turns a tap into "open that day". + */ +@Composable +private fun MonthWeekRow( + week: MonthWeek, + today: LocalDate, + month: YearMonth, + onOpenDay: (LocalDate) -> Unit, + modifier: Modifier = Modifier, +) { + val dark = isSystemInDarkTheme() + val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1 + val shownLanes = laneCount.coerceAtMost(MAX_EVENT_ROWS) + + BoxWithConstraints(modifier) { + val colW = maxWidth / 7 + + // Per-day background pills — same surfaceContainer rounded surface the + // week/day views use, so the three views share one visual language. + // Spanning bars draw on top of these, bridging cells, so they still read + // as one continuous event. + Row(Modifier.matchParentSize()) { + week.days.forEach { d -> + val inMonth = d.month == month.month && d.year == month.year + Box( + Modifier + .weight(1f) + .fillMaxHeight() + .padding(horizontal = CELL_GAP, vertical = 1.dp) + .background( + color = if (inMonth) MaterialTheme.colorScheme.surfaceContainer + else MaterialTheme.colorScheme.surfaceContainerLow, + shape = CELL_SHAPE, + ), + ) + } + } + + Column(Modifier.fillMaxSize().padding(top = CELL_TOP_PADDING)) { + Row(Modifier.fillMaxWidth()) { + week.days.forEach { d -> + DayNumberCell( + date = d, + isToday = d == today, + inMonth = d.month == month.month && d.year == month.year, + modifier = Modifier.weight(1f), + ) + } + } + // Breathing room between the day number (and today's circle) and the + // first event row. + Spacer(Modifier.height(DAY_NUMBER_GAP)) + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .clipToBounds(), ) { - repeat(7) { col -> - val date = gridStart.plus(row * 7 + col, DateTimeUnit.DAY) - val inMonth = - date.month == state.month.month && date.year == state.month.year - if (inMonth) { - DayCard( - date = date, - isToday = date == state.today, - data = state.cells[date], - onClick = { onOpenDay(date) }, - modifier = Modifier.weight(1f), + // Spanning bars on their shared lanes. + week.spans.filter { it.lane < shownLanes }.forEach { span -> + val cols = span.endCol - span.startCol + 1 + MonthBar( + event = span.event, + dark = dark, + continuesLeft = span.continuesLeft, + continuesRight = span.continuesRight, + modifier = Modifier + .offset( + x = colW * span.startCol, + y = EVENT_ROW_HEIGHT * span.lane, + ) + .width(colW * cols) + .height(EVENT_ROW_HEIGHT) + .padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp), + ) + } + // Single-day timed pills + overflow, per column. Pills fill the + // lane slots no bar occupies on THIS day (top-most first), so a + // bar-free day isn't pushed down by a multi-day event that only + // sits on other days of the week. + week.days.forEachIndexed { col, d -> + val timed = week.timedByDay[d].orEmpty() + val occupied = week.spans + .filter { it.lane < shownLanes && col in it.startCol..it.endCol } + .map { it.lane } + .toSet() + val freeSlots = (0 until MAX_EVENT_ROWS).filter { it !in occupied } + val pillsShown = timed.take(freeSlots.size) + pillsShown.forEachIndexed { i, ev -> + MonthBar( + event = ev, + dark = dark, + continuesLeft = false, + continuesRight = false, + modifier = Modifier + .offset( + x = colW * col, + y = EVENT_ROW_HEIGHT * freeSlots[i], + ) + .width(colW) + .height(EVENT_ROW_HEIGHT) + .padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp), + ) + } + val hidden = (week.countByDay[d] ?: 0) - occupied.size - pillsShown.size + if (hidden > 0) { + val hiddenColors = buildList { + week.spans + .filter { it.lane >= shownLanes && col in it.startCol..it.endCol } + .forEach { add(it.event.color) } + timed.drop(pillsShown.size).forEach { add(it.color) } + }.distinct().take(3) + OverflowDots( + colors = hiddenColors, + extra = hidden - hiddenColors.size, + dark = dark, + modifier = Modifier + .offset(x = colW * col, y = EVENT_ROW_HEIGHT * MAX_EVENT_ROWS) + .width(colW) + .padding(horizontal = 3.dp), ) - } else { - Spacer(Modifier.weight(1f)) } } } } - } -} -@OptIn(ExperimentalMaterial3ExpressiveApi::class) -@Composable -private fun DayCard( - date: LocalDate, - isToday: Boolean, - data: DayCellData?, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - val todayPrefix = stringResource(R.string.month_a11y_today_prefix) - val cellLabel = buildString { - if (isToday) append(todayPrefix).append(", ") - append(date.year).append('-') - append((date.month.ordinal + 1).toString().padStart(2, '0')).append('-') - append(date.day.toString().padStart(2, '0')) - data?.let { append(", ").append(it.count).append(" Events") } - } - - // M3 Expressive press feedback: a spatial spring from the active motion - // scheme drives a subtle scale, instead of a fixed easing curve. - val interactionSource = remember { MutableInteractionSource() } - val pressed by interactionSource.collectIsPressedAsState() - val scale by animateFloatAsState( - targetValue = if (pressed) 0.94f else 1f, - animationSpec = MaterialTheme.motionScheme.fastSpatialSpec(), - label = "day-card-press", - ) - - Card( - onClick = onClick, - interactionSource = interactionSource, - shape = MaterialTheme.shapes.medium, - colors = CardDefaults.cardColors( - containerColor = if (isToday) MaterialTheme.colorScheme.primaryContainer - else MaterialTheme.colorScheme.surfaceContainerHigh, - contentColor = if (isToday) MaterialTheme.colorScheme.onPrimaryContainer - else MaterialTheme.colorScheme.onSurface, - ), - modifier = modifier - .fillMaxSize() - .graphicsLayer { - scaleX = scale - scaleY = scale + // Tap layer: in month view a tap on any day opens that day. Padded and + // clipped to the background pill so the ripple matches it. + Row(Modifier.matchParentSize()) { + week.days.forEach { d -> + Box( + Modifier + .weight(1f) + .fillMaxHeight() + .padding(horizontal = CELL_GAP, vertical = 1.dp) + .clip(CELL_SHAPE) + .clickable { onOpenDay(d) }, + ) } - .semantics { contentDescription = cellLabel }, - ) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(top = 4.dp, bottom = 2.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - text = date.day.toString(), - style = MaterialTheme.typography.labelLarge, - fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal, - ) - Spacer(Modifier.height(2.dp)) - EventDotRow(data) } } } @Composable -private fun EventDotRow(data: DayCellData?) { - if (data == null || data.swatches.isEmpty()) { - Spacer(Modifier.height(6.dp)) - return +private fun DayNumberCell( + date: LocalDate, + isToday: Boolean, + inMonth: Boolean, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier.height(DAY_NUMBER_HEIGHT), + contentAlignment = Alignment.Center, + ) { + if (isToday) { + Box( + modifier = Modifier + .size(DAY_NUMBER_HEIGHT) + .background(MaterialTheme.colorScheme.primary, CircleShape), + contentAlignment = Alignment.Center, + ) { + Text( + text = date.day.toString(), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimary, + ) + } + } else { + Text( + text = date.day.toString(), + style = MaterialTheme.typography.labelMedium, + color = if (inMonth) MaterialTheme.colorScheme.onSurface + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + ) + } } - val dark = isSystemInDarkTheme() +} + +/** A filled event pill/bar — pastelized fill, title clipped to one line. */ +@Composable +private fun MonthBar( + event: de.jeanlucmakiola.calendula.domain.EventInstance, + dark: Boolean, + continuesLeft: Boolean, + continuesRight: Boolean, + modifier: Modifier = Modifier, +) { + val title = event.title.ifBlank { stringResource(R.string.event_untitled) } + val shape = RoundedCornerShape( + topStart = if (continuesLeft) 0.dp else 4.dp, + bottomStart = if (continuesLeft) 0.dp else 4.dp, + topEnd = if (continuesRight) 0.dp else 4.dp, + bottomEnd = if (continuesRight) 0.dp else 4.dp, + ) + Box( + modifier = modifier + .background(pastelize(event.color, dark), shape) + .padding(horizontal = 4.dp) + .semantics { contentDescription = title }, + contentAlignment = Alignment.CenterStart, + ) { + Text( + text = title, + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = Color.Black.copy(alpha = 0.8f), + ) + } +} + +/** Overflow row: a dot per hidden event (up to three) plus "+N" for the rest. */ +@Composable +private fun OverflowDots( + colors: List, + extra: Int, + dark: Boolean, + modifier: Modifier = Modifier, +) { Row( + modifier = modifier.height(EVENT_ROW_HEIGHT), horizontalArrangement = Arrangement.spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically, ) { - data.swatches.forEach { argb -> + colors.forEach { argb -> Box( modifier = Modifier .size(6.dp) .background(pastelize(argb, dark), CircleShape), ) } - if (data.count > data.swatches.size) { + if (extra > 0) { Text( - text = "+${data.count - data.swatches.size}", + text = "+$extra", 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 5cc13c3..0822035 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 @@ -1,20 +1,40 @@ package de.jeanlucmakiola.calendula.ui.month +import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.FailureReason import kotlinx.datetime.LocalDate import kotlinx.datetime.YearMonth /** - * Per-day aggregation surfaced to the month grid. We only need - * - the total event count (drives the optional "+N" indicator), and - * - up to three calendar colors for the dot row. - * - * The day cell never holds full event objects — the detail sheet pulls those - * lazily. + * An all-day or multi-day event laid out as one connected horizontal bar across + * a week row, [startCol]..[endCol], stacked on [lane] so overlapping spans don't + * collide. Mirrors the week view's [de.jeanlucmakiola.calendula.ui.week.AllDaySpan] + * but adds clip flags so a bar that started in an earlier week (or runs into a + * later one) drops its rounded cap on that side. */ -data class DayCellData( - val count: Int, - val swatches: List, +data class MonthSpan( + val event: EventInstance, + val startCol: Int, + val endCol: Int, + val lane: Int, + val continuesLeft: Boolean, + val continuesRight: Boolean, +) + +/** + * One week row of the grid with its events resolved for rendering. + * + * - [spans] are the all-day/multi-day bars, lanes already assigned for the row. + * - [timedByDay] holds the single-day timed events per date, sorted by start; + * these render as filled pills beneath the bar lanes in their own cell. + * - [countByDay] is the total number of events touching each date (bars + pills), + * so the cell can compute the "+N" overflow once the visible-row cap is known. + */ +data class MonthWeek( + val days: List, + val spans: List, + val timedByDay: Map>, + val countByDay: Map, ) sealed interface MonthUiState { @@ -23,6 +43,6 @@ sealed interface MonthUiState { data class Success( val month: YearMonth, val today: LocalDate, - val cells: Map, + val weeks: List, ) : MonthUiState } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt index 3f21ff9..31328b5 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt @@ -10,6 +10,8 @@ import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.FailureReason +import de.jeanlucmakiola.calendula.ui.week.coversDay +import de.jeanlucmakiola.calendula.ui.week.layoutAllDay import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow @@ -71,7 +73,7 @@ class MonthViewModel @Inject constructor( repository.calendars(), repository.instances(range), ) { calendars, instances -> - buildState(ym, calendars, instances) + buildState(ym, ws, calendars, instances) } } .catch { emit(MonthUiState.Failure(FailureReason.ProviderUnavailable)) } @@ -96,25 +98,64 @@ class MonthViewModel @Inject constructor( private fun buildState( ym: YearMonth, + weekStart: DayOfWeek, calendars: List, instances: List, ): MonthUiState { if (calendars.isEmpty()) { return MonthUiState.Failure(FailureReason.NoCalendarsConfigured) } - val byDay = instances.groupBy { it.start.toLocalDateTime(zone).date } - .mapValues { (_, evs) -> - DayCellData( - count = evs.size, - swatches = evs.map { it.color }.distinct().take(3), - ) - } return MonthUiState.Success( month = ym, today = todayDate, - cells = byDay, + weeks = layoutMonth(ym, weekStart, instances), ) } + + /** + * Split the grid into week rows and resolve each row's events. An event is a + * spanning bar when it's all-day or touches more than one of the row's days; + * everything else is a single-day timed pill. Bars get lanes from the shared + * [layoutAllDay] so a multi-day event stays on one row across the week. + */ + private fun layoutMonth( + ym: YearMonth, + weekStart: DayOfWeek, + instances: List, + ): List { + val firstOfMonth = LocalDate(ym.year, ym.month, 1) + val gridStart = firstOfMonth.startOfGridWeek(weekStart) + val leadOffset = ((firstOfMonth.dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7 + val daysInMonth = + java.time.YearMonth.of(ym.year, ym.month.ordinal + 1).lengthOfMonth() + val weekCount = (leadOffset + daysInMonth + 6) / 7 + + return (0 until weekCount).map { row -> + val days = (0 until 7).map { gridStart.plus(row * 7 + it, DateTimeUnit.DAY) } + val weekEvents = instances.filter { ev -> days.any { ev.coversDay(it, zone) } } + val (bars, singles) = weekEvents.partition { ev -> + ev.isAllDay || days.count { ev.coversDay(it, zone) } > 1 + } + val spans = layoutAllDay(bars, days, zone).map { s -> + MonthSpan( + event = s.event, + startCol = s.startCol, + endCol = s.endCol, + lane = s.lane, + continuesLeft = s.event.coversDay(days.first().minus(1, DateTimeUnit.DAY), zone), + continuesRight = s.event.coversDay(days.last().plus(1, DateTimeUnit.DAY), zone), + ) + } + MonthWeek( + days = days, + spans = spans, + timedByDay = days.associateWith { d -> + singles.filter { it.coversDay(d, zone) }.sortedBy { it.start } + }, + countByDay = days.associateWith { d -> weekEvents.count { it.coversDay(d, zone) } }, + ) + } + } } /**