feat(agenda): day-aware time line for multi-day events

A multi-day event repeated the same "start – end" on every day it spans
("14:00 – 14:00"), which reads as meaningless. Show only the part
relevant to each day, with a "→" marking that it carries past the day's
boundary: the first day shows the start ("14:00 →"), the last day the
end ("→ 10:00"), and whole days in between an all-day span arriving from
and continuing into their neighbours ("→ All day →"). Single-day rows are
unchanged.

Factors the span first/last-day resolution into shared EventInstance
helpers reused by groupAgendaDays and the label.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-19 11:39:01 +02:00
parent 19baed9291
commit 3feafe38f2
2 changed files with 54 additions and 13 deletions

View File

@@ -374,6 +374,7 @@ private fun AgendaList(
) { index, event ->
AgendaEventRow(
event = event,
day = day.date,
position = positionOf(index, day.events.size),
dimmed = dimPast && event.hasEnded(now),
modifier = animateItemMotion(),
@@ -452,6 +453,7 @@ private fun AgendaEmptyDayRow(onClick: () -> Unit) {
@Composable
private fun AgendaEventRow(
event: EventInstance,
day: LocalDate,
position: Position,
dimmed: Boolean,
modifier: Modifier = Modifier,
@@ -463,7 +465,7 @@ private fun AgendaEventRow(
GroupedRow(
modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier,
title = title,
summary = agendaTimeSummary(event),
summary = agendaTimeSummary(event, day),
position = position,
minHeight = 64.dp,
leading = {
@@ -557,16 +559,41 @@ private fun agendaDayLabel(date: LocalDate, today: LocalDate): String {
return if (relative != null) "$relative · $formatted" else formatted
}
/** Time line under the title: "09:00 10:00 · Location", "All day", etc. */
/**
* Time line under the title: "09:00 10:00 · Location", "All day", etc.
*
* A multi-day event shows only the part relevant to [day], with a "→" marking
* that it carries past this day's boundary: its first day shows the start
* ("14:00 →"), its last day the end ("→ 10:00"), and any whole day in between
* reads as an all-day span arriving from and continuing into its neighbours
* ("→ All day →").
*/
@Composable
private fun agendaTimeSummary(event: EventInstance): String {
val time = if (event.isAllDay) {
stringResource(R.string.event_detail_all_day)
private fun agendaTimeSummary(event: EventInstance, day: LocalDate): String {
val allDayLabel = stringResource(R.string.event_detail_all_day)
val is24Hour = LocalUse24HourFormat.current
val locale = currentLocale()
val time = if (event.spansMultipleDays(zone)) {
val continuesBefore = day > event.spanFirstDay(zone)
val continuesAfter = day < event.spanLastDay(zone)
val core = when {
event.isAllDay -> allDayLabel
!continuesBefore -> formatTime(event.start, is24Hour, locale) // the first day
!continuesAfter -> formatTime(event.end, is24Hour, locale) // the last day
else -> allDayLabel // a full middle day
}
buildString {
if (continuesBefore) append("")
append(core)
if (continuesAfter) append("")
}
} else if (event.isAllDay) {
allDayLabel
} else {
val is24Hour = LocalUse24HourFormat.current
val locale = currentLocale()
"${formatTime(event.start, is24Hour, locale)} ${formatTime(event.end, is24Hour, locale)}"
}
val location = event.location?.takeIf { it.isNotBlank() }
return if (location != null) "$time · $location" else time
}

View File

@@ -9,6 +9,24 @@ import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Duration.Companion.milliseconds
/** The first calendar day this event occupies, in [zone]. */
fun EventInstance.spanFirstDay(zone: TimeZone): LocalDate =
start.toLocalDateTime(zone).date
/**
* The last calendar day this event actually occupies, in [zone]. An event ending
* exactly at midnight (all-day events end at the exclusive next-midnight) does
* not reach into that boundary day, so resolve the instant just before [end].
*/
fun EventInstance.spanLastDay(zone: TimeZone): LocalDate {
val lastInstant = if (end > start) end - 1.milliseconds else start
return lastInstant.toLocalDateTime(zone).date
}
/** Whether this event occupies more than one calendar day in [zone]. */
fun EventInstance.spansMultipleDays(zone: TimeZone): Boolean =
spanFirstDay(zone) != spanLastDay(zone)
/** One calendar day with at least one event, for the agenda list. */
data class AgendaDay(
val date: LocalDate,
@@ -35,12 +53,8 @@ fun groupAgendaDays(
): List<AgendaDay> {
val byDay = sortedMapOf<LocalDate, MutableList<EventInstance>>()
for (instance in instances) {
val firstDay = instance.start.toLocalDateTime(zone).date.coerceAtLeast(anchor)
// The last day the event actually occupies. An event ending exactly at
// midnight (all-day events end at the exclusive next-midnight) does not
// reach into that boundary day, so resolve the instant just before [end].
val lastInstant = if (instance.end > instance.start) instance.end - 1.milliseconds else instance.start
val lastDay = lastInstant.toLocalDateTime(zone).date.coerceAtMost(windowEnd)
val firstDay = instance.spanFirstDay(zone).coerceAtLeast(anchor)
val lastDay = instance.spanLastDay(zone).coerceAtMost(windowEnd)
var day = firstDay
while (day <= lastDay) {
byDay.getOrPut(day) { mutableListOf() }.add(instance)