From 498250650c3c320ff13a5ef703e1551dcb1df183 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 20 Jul 2026 16:36:57 +0200 Subject: [PATCH] refactor(month): extract per-week layout and share the agenda's rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Groundwork for the month view style setting (#38, #53); no behaviour change. - Split layoutCalendarWeek out of layoutMonthWeeks so the continuous style, which streams weeks and has no enclosing YearMonth to slice by, lays rows out identically. layoutMonthWeeks (also used by the month widget) keeps its signature and becomes a loop over it. - Add MonthUiState.Success.instancesByDay: the grid's events keyed by date and uncapped, so the split style's day pane can list a date without a second provider query — the month grid range already covers it. - Move AgendaDayHeader / AgendaEmptyDayRow / AgendaEventRow and their label helpers into AgendaRows.kt as internal, so the split pane reuses the agenda's row vocabulary instead of growing a parallel one. AgendaEmptyDayRow takes its text as a parameter now that it serves more than "nothing left today". - Add the month package's first JVM tests, covering week counts, span continuation across row boundaries, lane stacking and instancesByDay. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/agenda/AgendaRows.kt | 206 ++++++++++++++++ .../calendula/ui/agenda/AgendaScreen.kt | 180 +------------- .../calendula/ui/month/MonthUiState.kt | 8 + .../calendula/ui/month/MonthViewModel.kt | 80 +++++-- .../calendula/ui/month/MonthLayoutTest.kt | 220 ++++++++++++++++++ 5 files changed, 495 insertions(+), 199 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRows.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthLayoutTest.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRows.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRows.kt new file mode 100644 index 0000000..9aedc1c --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRows.kt @@ -0,0 +1,206 @@ +package de.jeanlucmakiola.calendula.ui.agenda + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Coffee +import androidx.compose.material3.Card +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha +import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors +import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat +import de.jeanlucmakiola.calendula.ui.common.eventFill +import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.Position +import de.jeanlucmakiola.floret.locale.currentLocale +import de.jeanlucmakiola.floret.locale.localizedDateFormatter +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.plus +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Instant +import java.util.Locale + +// The agenda's row vocabulary, split out of AgendaScreen so the month view's +// split style can list a day with exactly the same visual language instead of +// growing a parallel set of event rows. + +@Composable +internal fun AgendaDayHeader( + date: LocalDate, + today: LocalDate, + onOpenDay: (LocalDate) -> Unit, +) { + Surface( + color = MaterialTheme.colorScheme.surface, + modifier = Modifier + .fillMaxWidth() + .clickable { onOpenDay(date) }, + ) { + Text( + text = agendaDayLabel(date, today), + style = MaterialTheme.typography.titleSmall, + color = if (date == today) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp), + ) + } +} + +/** + * A card standing in for a day with no events — the same coffee-cup motif as the + * agenda's full-screen empty state, boxed into a card so the day keeps a visible slot + * rather than a bare header. Used for an anchored, event-less today (#35) and for + * an empty selected day in the month view's split style. + */ +@Composable +internal fun AgendaEmptyDayRow(text: String, onClick: () -> Unit) { + Card( + // Match a single event row's resting corner radius (floret groupedShape). + shape = RoundedCornerShape(22.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp) + .clickable(onClick = onClick), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 18.dp, horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = Icons.Filled.Coffee, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(36.dp), + ) + Spacer(Modifier.height(8.dp)) + Text( + text = text, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + } +} + +@Composable +internal fun AgendaEventRow( + event: EventInstance, + day: LocalDate, + zone: TimeZone, + position: Position, + dimmed: Boolean, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + val dark = isSystemInDarkTheme() + val soften = LocalSoftenColors.current + val title = event.title.ifBlank { stringResource(R.string.event_untitled) } + GroupedRow( + modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier, + title = title, + summary = agendaTimeSummary(event, day, zone), + position = position, + minHeight = 64.dp, + leading = { + Box( + modifier = Modifier + .size(width = 6.dp, height = 36.dp) + .clip(RoundedCornerShape(3.dp)) + .background(eventFill(event.color, dark, soften)), + ) + }, + onClick = onClick, + ) +} + +/** "Today · Wed, 17. Jun 2026" — relative word for today/tomorrow, else the date. */ +@Composable +internal fun agendaDayLabel(date: LocalDate, today: LocalDate): String { + val relative = when (date) { + today -> stringResource(R.string.agenda_header_today) + today.plus(1, DateTimeUnit.DAY) -> stringResource(R.string.agenda_header_tomorrow) + else -> null + } + val formatted = formatAgendaDate(date) + return if (relative != null) "$relative · $formatted" else formatted +} + +/** + * Time line under the title: "09:00 – 10:00 · Location", "All day", etc. + * + * A multi-day event shows only the part relevant to [day], spelled out so each + * day reads on its own: its first day names the start ("Starts 14:00"), its last + * day the end ("Ends 10:00"), and any whole day in between reads as "All day". + * An all-day multi-day event is simply "All day" on every day it covers. + */ +@Composable +internal fun agendaTimeSummary(event: EventInstance, day: LocalDate, zone: TimeZone): String { + val is24Hour = LocalUse24HourFormat.current + val locale = currentLocale() + + val time = when (val label = agendaTimeLabel(event, day, zone)) { + AgendaTimeLabel.AllDay -> stringResource(R.string.event_detail_all_day) + is AgendaTimeLabel.Starts -> stringResource( + R.string.agenda_span_starts, + formatTime(label.start, zone, is24Hour, locale), + ) + is AgendaTimeLabel.Ends -> stringResource( + R.string.agenda_span_ends, + formatTime(label.end, zone, is24Hour, locale), + ) + is AgendaTimeLabel.Range -> "${formatTime(label.start, zone, is24Hour, locale)} – " + + formatTime(label.end, zone, is24Hour, locale) + } + + val location = event.location?.takeIf { it.isNotBlank() } + return if (location != null) "$time · $location" else time +} + +private fun formatTime( + instant: Instant, + zone: TimeZone, + is24Hour: Boolean, + locale: Locale, +): String { + val t = instant.toLocalDateTime(zone).time + return formatTimeOfDay(t.hour, t.minute, is24Hour, locale) +} + +private fun formatAgendaDate(date: LocalDate): String { + val locale = Locale.getDefault() + val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day) + // Weekday + date in the locale's own field order (e.g. "Wed, Jun 17, 2026" + // vs "Mi., 17. Juni 2026") rather than a hardcoded day-month-year layout. + return localizedDateFormatter(locale, "EEEdMMMy").format(java) +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt index f259929..83006f0 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt @@ -1,9 +1,6 @@ package de.jeanlucmakiola.calendula.ui.agenda import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -17,13 +14,11 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Coffee import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Search import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Card import androidx.compose.material3.DrawerValue import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.ExperimentalMaterial3Api @@ -32,7 +27,6 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults @@ -46,8 +40,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign @@ -59,8 +51,6 @@ import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.hasEnded import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker -import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors -import de.jeanlucmakiola.calendula.ui.common.eventFill import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel import de.jeanlucmakiola.floret.identity.animateItemMotion import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer @@ -69,25 +59,15 @@ import de.jeanlucmakiola.calendula.ui.common.TodayAction import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS -import de.jeanlucmakiola.floret.locale.localizedDateFormatter -import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha -import de.jeanlucmakiola.floret.components.GroupedRow -import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute import de.jeanlucmakiola.floret.locale.currentLocale -import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat -import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay import kotlinx.coroutines.launch -import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone -import kotlinx.datetime.plus -import kotlinx.datetime.toLocalDateTime import kotlin.time.Instant -import java.util.Locale // No file-level zone constant here on purpose: it would be fixed for the process // lifetime and drift from the zone AgendaViewModel groups in after a device @@ -370,7 +350,10 @@ private fun AgendaList( if (day.events.isEmpty()) { // An anchored, event-less today (#35) — "nothing left today". item(key = "placeholder-${day.date}") { - AgendaEmptyDayRow(onClick = { onOpenDay(day.date) }) + AgendaEmptyDayRow( + text = stringResource(R.string.agenda_no_more_today), + onClick = { onOpenDay(day.date) }, + ) } } else { itemsIndexed( @@ -396,100 +379,6 @@ private fun AgendaList( } } -@Composable -private fun AgendaDayHeader( - date: LocalDate, - today: LocalDate, - onOpenDay: (LocalDate) -> Unit, -) { - Surface( - color = MaterialTheme.colorScheme.surface, - modifier = Modifier - .fillMaxWidth() - .clickable { onOpenDay(date) }, - ) { - Text( - text = agendaDayLabel(date, today), - style = MaterialTheme.typography.titleSmall, - color = if (date == today) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp), - ) - } -} - -/** - * A card under an anchored, event-less today (#35) — the same coffee-cup motif - * as the full-screen [AgendaEmpty] state, boxed into a card so today keeps a - * visible slot when nothing is left rather than a bare header. - */ -@Composable -private fun AgendaEmptyDayRow(onClick: () -> Unit) { - Card( - // Match a single event row's resting corner radius (floret groupedShape). - shape = RoundedCornerShape(22.dp), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 4.dp) - .clickable(onClick = onClick), - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 18.dp, horizontal = 16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Icon( - imageVector = Icons.Filled.Coffee, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(36.dp), - ) - Spacer(Modifier.height(8.dp)) - Text( - text = stringResource(R.string.agenda_no_more_today), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - ) - } - } -} - -@Composable -private fun AgendaEventRow( - event: EventInstance, - day: LocalDate, - zone: TimeZone, - position: Position, - dimmed: Boolean, - modifier: Modifier = Modifier, - onClick: () -> Unit, -) { - val dark = isSystemInDarkTheme() - val soften = LocalSoftenColors.current - val title = event.title.ifBlank { stringResource(R.string.event_untitled) } - GroupedRow( - modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier, - title = title, - summary = agendaTimeSummary(event, day, zone), - position = position, - minHeight = 64.dp, - leading = { - Box( - modifier = Modifier - .size(width = 6.dp, height = 36.dp) - .clip(RoundedCornerShape(3.dp)) - .background(eventFill(event.color, dark, soften)), - ) - }, - onClick = onClick, - ) -} - @Composable private fun AgendaEmpty(modifier: Modifier = Modifier) { Column( @@ -559,64 +448,3 @@ private fun AgendaTopBar( scrollBehavior = scrollBehavior, ) } - -/** "Today · Wed, 17. Jun 2026" — relative word for today/tomorrow, else the date. */ -@Composable -private fun agendaDayLabel(date: LocalDate, today: LocalDate): String { - val relative = when (date) { - today -> stringResource(R.string.agenda_header_today) - today.plus(1, DateTimeUnit.DAY) -> stringResource(R.string.agenda_header_tomorrow) - else -> null - } - val formatted = formatAgendaDate(date) - return if (relative != null) "$relative · $formatted" else formatted -} - -/** - * Time line under the title: "09:00 – 10:00 · Location", "All day", etc. - * - * A multi-day event shows only the part relevant to [day], spelled out so each - * day reads on its own: its first day names the start ("Starts 14:00"), its last - * day the end ("Ends 10:00"), and any whole day in between reads as "All day". - * An all-day multi-day event is simply "All day" on every day it covers. - */ -@Composable -private fun agendaTimeSummary(event: EventInstance, day: LocalDate, zone: TimeZone): String { - val is24Hour = LocalUse24HourFormat.current - val locale = currentLocale() - - val time = when (val label = agendaTimeLabel(event, day, zone)) { - AgendaTimeLabel.AllDay -> stringResource(R.string.event_detail_all_day) - is AgendaTimeLabel.Starts -> stringResource( - R.string.agenda_span_starts, - formatTime(label.start, zone, is24Hour, locale), - ) - is AgendaTimeLabel.Ends -> stringResource( - R.string.agenda_span_ends, - formatTime(label.end, zone, is24Hour, locale), - ) - is AgendaTimeLabel.Range -> "${formatTime(label.start, zone, is24Hour, locale)} – " + - formatTime(label.end, zone, is24Hour, locale) - } - - val location = event.location?.takeIf { it.isNotBlank() } - return if (location != null) "$time · $location" else time -} - -private fun formatTime( - instant: Instant, - zone: TimeZone, - is24Hour: Boolean, - locale: Locale, -): String { - val t = instant.toLocalDateTime(zone).time - return formatTimeOfDay(t.hour, t.minute, is24Hour, locale) -} - -private fun formatAgendaDate(date: LocalDate): String { - val locale = Locale.getDefault() - val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day) - // Weekday + date in the locale's own field order (e.g. "Wed, Jun 17, 2026" - // vs "Mi., 17. Juni 2026") rather than a hardcoded day-month-year layout. - return localizedDateFormatter(locale, "EEEdMMMy").format(java) -} 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 0822035..24f950c 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 @@ -40,9 +40,17 @@ data class MonthWeek( sealed interface MonthUiState { data object Loading : MonthUiState data class Failure(val reason: FailureReason) : MonthUiState + + /** + * [weeks] is what the grid draws; [instancesByDay] is the same events keyed by + * date and *uncapped*, so the split style's day pane can list everything on a + * date without a second provider query — the month grid range already covers + * it. All-day events sort first, then by start. + */ data class Success( val month: YearMonth, val today: LocalDate, val weeks: List, + val instancesByDay: Map> = emptyMap(), ) : 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 8abd2f0..87a3f19 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 @@ -117,10 +117,12 @@ class MonthViewModel @Inject constructor( if (calendars.isEmpty()) { return MonthUiState.Failure(FailureReason.NoCalendarsConfigured) } + val weeks = layoutMonthWeeks(ym, weekStart, instances, zone) return MonthUiState.Success( month = ym, today = todayDate, - weeks = layoutMonthWeeks(ym, weekStart, instances, zone), + weeks = weeks, + instancesByDay = instancesByDay(weeks.flatMap { it.days }, instances, zone), ) } } @@ -149,31 +151,63 @@ internal fun layoutMonthWeeks( 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) } }, - ) + layoutCalendarWeek(days, instances, zone) } } +/** + * Resolve one week row's events for rendering. Split out of [layoutMonthWeeks] so + * the continuous style — which streams weeks rather than months and so has no + * enclosing [YearMonth] to slice by — lays each row out identically. + * + * [days] must be the row's seven consecutive dates, in display order. + */ +internal fun layoutCalendarWeek( + days: List, + instances: List, + zone: TimeZone, +): MonthWeek { + 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), + ) + } + return 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) } }, + ) +} + +/** + * Every event touching each of [days], all-day first then by start time. Unlike + * [MonthWeek.timedByDay] this keeps multi-day and all-day events on every date + * they cover and applies no display cap, so the split style's day pane can list a + * date in full without querying the provider again. + */ +internal fun instancesByDay( + days: List, + instances: List, + zone: TimeZone, +): Map> = + days.associateWith { day -> + instances + .filter { it.coversDay(day, zone) } + .sortedWith(compareByDescending { it.isAllDay }.thenBy { it.start }) + } + /** * The on-screen grid spans 6 weeks anchored on [weekStart]. Includes the * trailing days of the previous month and the leading days of the next month. diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthLayoutTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthLayoutTest.kt new file mode 100644 index 0000000..52e43f8 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthLayoutTest.kt @@ -0,0 +1,220 @@ +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.TimeZone +import kotlinx.datetime.YearMonth +import kotlinx.datetime.atTime +import kotlinx.datetime.plus +import kotlinx.datetime.toInstant +import kotlin.time.Instant +import org.junit.jupiter.api.Test + +class MonthLayoutTest { + + private val zone = TimeZone.UTC + + // 2026-06-01 is a Monday, so a Monday-anchored June grid starts on the 1st. + private val jun = YearMonth(2026, kotlinx.datetime.Month.JUNE) + private val jun1 = LocalDate(2026, 6, 1) + private val jun8 = LocalDate(2026, 6, 8) + private val weekOf8th = (0..6).map { jun8.plus(it, DateTimeUnit.DAY) } + + private fun at(date: LocalDate, h: Int, m: Int = 0): Instant = + date.atTime(h, m).toInstant(zone) + + private fun timed( + date: LocalDate, + startHour: Int, + endHour: Int, + id: Long = 1L, + title: String = "E", + ) = EventInstance( + instanceId = id, + eventId = id, + calendarId = 1L, + title = title, + start = at(date, startHour), + end = at(date, endHour), + isAllDay = false, + color = 0xFF112233.toInt(), + location = null, + ) + + /** All-day events live at UTC midnights with an *exclusive* end. */ + private fun allDay( + from: LocalDate, + toInclusive: LocalDate = from, + id: Long = 100L, + title: String = "A", + ) = EventInstance( + instanceId = id, + eventId = id, + calendarId = 1L, + title = title, + start = from.atTime(0, 0).toInstant(TimeZone.UTC), + end = toInclusive.plus(1, DateTimeUnit.DAY).atTime(0, 0).toInstant(TimeZone.UTC), + isAllDay = true, + color = 0xFF445566.toInt(), + location = null, + ) + + @Test + fun `startOfGridWeek snaps back to the configured week start`() { + val wed = LocalDate(2026, 6, 10) + assertThat(wed.startOfGridWeek(DayOfWeek.MONDAY)).isEqualTo(jun8) + assertThat(jun8.startOfGridWeek(DayOfWeek.MONDAY)).isEqualTo(jun8) + // A Sunday-anchored week containing the 10th starts on the 7th. + assertThat(wed.startOfGridWeek(DayOfWeek.SUNDAY)).isEqualTo(LocalDate(2026, 6, 7)) + } + + @Test + fun `monthGridRange always covers 42 days from the grid start`() { + val range = monthGridRange(jun, DayOfWeek.MONDAY, zone) + assertThat(range.start).isEqualTo(at(jun1, 0)) + // 42 days inclusive → the last second of 2026-07-12. + assertThat(range.endInclusive) + .isEqualTo(LocalDate(2026, 7, 12).atTime(23, 59, 59).toInstant(zone)) + } + + @Test + fun `week count follows the month's shape rather than a fixed six rows`() { + // June 2026: starts Monday, 30 days → 5 rows. + assertThat(layoutMonthWeeks(jun, DayOfWeek.MONDAY, emptyList(), zone)).hasSize(5) + // August 2026: starts Saturday, 31 days → spills to 6 rows. + val aug = YearMonth(2026, kotlinx.datetime.Month.AUGUST) + assertThat(layoutMonthWeeks(aug, DayOfWeek.MONDAY, emptyList(), zone)).hasSize(6) + // February 2021: starts Monday, 28 days → exactly 4 rows. + val feb = YearMonth(2021, kotlinx.datetime.Month.FEBRUARY) + assertThat(layoutMonthWeeks(feb, DayOfWeek.MONDAY, emptyList(), zone)).hasSize(4) + } + + @Test + fun `layoutMonthWeeks rows are contiguous and seven days wide`() { + val weeks = layoutMonthWeeks(jun, DayOfWeek.MONDAY, emptyList(), zone) + assertThat(weeks.first().days.first()).isEqualTo(jun1) + weeks.forEach { assertThat(it.days).hasSize(7) } + val allDays = weeks.flatMap { it.days } + allDays.zipWithNext { a, b -> assertThat(b).isEqualTo(a.plus(1, DateTimeUnit.DAY)) } + } + + @Test + fun `a multi-day event becomes one span across its columns`() { + val ev = allDay(LocalDate(2026, 6, 10), LocalDate(2026, 6, 12)) + val week = layoutCalendarWeek(weekOf8th, listOf(ev), zone) + + assertThat(week.spans).hasSize(1) + val span = week.spans.single() + assertThat(span.startCol).isEqualTo(2) // Wednesday the 10th + assertThat(span.endCol).isEqualTo(4) // Friday the 12th + assertThat(span.lane).isEqualTo(0) + assertThat(span.continuesLeft).isFalse() + assertThat(span.continuesRight).isFalse() + } + + @Test + fun `a span running past the row end is flagged as continuing`() { + // Saturday the 13th through Tuesday the 16th straddles the row boundary. + val ev = allDay(LocalDate(2026, 6, 13), LocalDate(2026, 6, 16)) + val week = layoutCalendarWeek(weekOf8th, listOf(ev), zone) + + val span = week.spans.single() + assertThat(span.startCol).isEqualTo(5) + assertThat(span.endCol).isEqualTo(6) + assertThat(span.continuesLeft).isFalse() + assertThat(span.continuesRight).isTrue() + + // The following row picks it up with the flags mirrored. + val nextRow = layoutCalendarWeek( + weekOf8th.map { it.plus(7, DateTimeUnit.DAY) }, + listOf(ev), + zone, + ) + val tail = nextRow.spans.single() + assertThat(tail.startCol).isEqualTo(0) + assertThat(tail.endCol).isEqualTo(1) + assertThat(tail.continuesLeft).isTrue() + assertThat(tail.continuesRight).isFalse() + } + + @Test + fun `overlapping spans are stacked on separate lanes`() { + val a = allDay(LocalDate(2026, 6, 9), LocalDate(2026, 6, 11), id = 1L) + val b = allDay(LocalDate(2026, 6, 10), LocalDate(2026, 6, 12), id = 2L) + val week = layoutCalendarWeek(weekOf8th, listOf(a, b), zone) + + assertThat(week.spans.map { it.lane }).containsExactly(0, 1) + } + + @Test + fun `single-day timed events stay pills and sort by start`() { + val late = timed(LocalDate(2026, 6, 10), 14, 15, id = 1L) + val early = timed(LocalDate(2026, 6, 10), 9, 10, id = 2L) + val week = layoutCalendarWeek(weekOf8th, listOf(late, early), zone) + + assertThat(week.spans).isEmpty() + assertThat(week.timedByDay[LocalDate(2026, 6, 10)]?.map { it.instanceId }) + .containsExactly(2L, 1L) + .inOrder() + assertThat(week.timedByDay[LocalDate(2026, 6, 11)]).isEmpty() + } + + @Test + fun `countByDay totals bars and pills on each date`() { + val span = allDay(LocalDate(2026, 6, 10), LocalDate(2026, 6, 12), id = 1L) + val pill = timed(LocalDate(2026, 6, 10), 9, 10, id = 2L) + val week = layoutCalendarWeek(weekOf8th, listOf(span, pill), zone) + + assertThat(week.countByDay[LocalDate(2026, 6, 10)]).isEqualTo(2) + assertThat(week.countByDay[LocalDate(2026, 6, 11)]).isEqualTo(1) + assertThat(week.countByDay[LocalDate(2026, 6, 13)]).isEqualTo(0) + } + + @Test + fun `layoutMonthWeeks agrees with laying each row out on its own`() { + val events = listOf( + allDay(LocalDate(2026, 6, 10), LocalDate(2026, 6, 12), id = 1L), + timed(LocalDate(2026, 6, 18), 9, 10, id = 2L), + ) + val weeks = layoutMonthWeeks(jun, DayOfWeek.MONDAY, events, zone) + + weeks.forEach { row -> + assertThat(row).isEqualTo(layoutCalendarWeek(row.days, events, zone)) + } + } + + @Test + fun `instancesByDay puts all-day events first then orders by start`() { + val allDayEv = allDay(LocalDate(2026, 6, 10), id = 1L) + val late = timed(LocalDate(2026, 6, 10), 14, 15, id = 2L) + val early = timed(LocalDate(2026, 6, 10), 9, 10, id = 3L) + + val byDay = instancesByDay(weekOf8th, listOf(late, early, allDayEv), zone) + + assertThat(byDay.getValue(LocalDate(2026, 6, 10)).map { it.instanceId }) + .containsExactly(1L, 3L, 2L) + .inOrder() + } + + @Test + fun `instancesByDay repeats a multi-day event on every date it covers`() { + val ev = allDay(LocalDate(2026, 6, 10), LocalDate(2026, 6, 12)) + val byDay = instancesByDay(weekOf8th, listOf(ev), zone) + + assertThat(byDay.getValue(LocalDate(2026, 6, 9))).isEmpty() + assertThat(byDay.getValue(LocalDate(2026, 6, 10))).hasSize(1) + assertThat(byDay.getValue(LocalDate(2026, 6, 11))).hasSize(1) + assertThat(byDay.getValue(LocalDate(2026, 6, 12))).hasSize(1) + assertThat(byDay.getValue(LocalDate(2026, 6, 13))).isEmpty() + } + + @Test + fun `instancesByDay covers every date in the grid, empty ones included`() { + val byDay = instancesByDay(weekOf8th, emptyList(), zone) + assertThat(byDay.keys).containsExactlyElementsIn(weekOf8th) + assertThat(byDay.values.flatten()).isEmpty() + } +}