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