Compare commits

...

8 Commits

Author SHA1 Message Date
fd3bcdf2f7 Merge pull request 'fix(agenda): show multi-day events on every day they span' (!83) from fix/agenda-multiday into release/v2.16.0
Reviewed-on: #83
2026-07-19 10:16:56 +00:00
7de9b2f81b fix(agenda): share the day-aware time label with the widget
All checks were successful
Translations / check (pull_request) Successful in 6s
CI / ci (pull_request) Successful in 5m45s
Code-review follow-up.

- The multi-day expansion in groupAgendaDays is shared with the agenda
  widget, but only the screen's summary was made day-aware — so the
  widget rendered the raw "start – end" on every spanned day, the very
  bug the screen fix cured. Hoist a pure agendaTimeLabel(event, day,
  zone) into the shared agenda layer and resolve strings from it in both
  the screen and the widget, so they label identically. (findings 1, 2)

- groupAgendaDays could silently drop an instance whose clamped span was
  empty (firstDay > lastDay); floor lastDay at firstDay so a returned
  instance always surfaces on at least its first visible day. (finding 3)

- agendaTimeLabel resolves the span days once instead of the summary
  recomputing them 2–3× per row. (finding 5)

Finding 4 (within-day sort) needs no change: sorting by absolute start
already places a still-running multi-day event at the top of each day it
continues into, which is chronologically correct (it is ongoing from
that day's midnight), and at its real start time on its first day.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 12:06:40 +02:00
3ec46c8631 fix(agenda): resolve all-day span days in UTC, not the device zone
Review follow-up. spanFirstDay/spanLastDay resolved every event in the
device zone, but all-day events live at UTC midnights with an exclusive
end — east of UTC (e.g. Europe/Berlin) that pushed spanLastDay onto the
next day, so a single-day all-day event reported spansMultipleDays and
leaked onto a second agenda day. Resolve all-day dates in UTC, matching
the Week view and detail card. Adds eastern-zone regression tests that
the prior UTC-only tests could not catch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 11:53:21 +02:00
529dc9c374 Merge remote-tracking branch 'origin/release/v2.16.0' into fix/agenda-multiday 2026-07-19 11:50:29 +02:00
b5c2930609 feat(agenda): spell out multi-day time line instead of arrows
The "→" arrow convention read as unclear. Spell each day out instead:
the first day names the start ("Starts 14:00"), the last day the end
("Ends 10:00"), and whole days in between read as "All day". All-day
multi-day events stay "All day" on every day. Single-day rows unchanged.

Adds agenda_span_starts / agenda_span_ends (owes Weblate backfill).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 11:42:15 +02:00
3feafe38f2 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>
2026-07-19 11:39:01 +02:00
19baed9291 fix(agenda): scope LazyColumn key by day for spanning events
A multi-day event now appears under every day it spans, so keying its
row by instanceId alone repeated the key across days and crashed the
LazyColumn ("Key already used") on scroll. Scope the key by day.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 11:30:00 +02:00
7ed1d89b66 fix(agenda): list multi-day events on every day they span
groupAgendaDays keyed each instance by its start day alone, so a
multi-day event surfaced only on its first day and vanished from the
rest of its span in both the Agenda screen and the agenda widget.

Expand each instance across every day from its start (clamped to the
anchor for ongoing events) through its last occupied day, bounded by the
visible window end. An event ending exactly at midnight — including the
exclusive next-midnight all-day events end at — does not reach that
boundary day.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 11:16:08 +02:00
8 changed files with 372 additions and 36 deletions

View File

@@ -367,10 +367,14 @@ private fun AgendaList(
} else { } else {
itemsIndexed( itemsIndexed(
items = day.events, items = day.events,
key = { _, event -> event.instanceId }, // Scope the key by day: a multi-day event appears under every
// day it spans, so its instanceId alone is not unique across
// the list (LazyColumn requires unique keys).
key = { _, event -> "${day.date}-${event.instanceId}" },
) { index, event -> ) { index, event ->
AgendaEventRow( AgendaEventRow(
event = event, event = event,
day = day.date,
position = positionOf(index, day.events.size), position = positionOf(index, day.events.size),
dimmed = dimPast && event.hasEnded(now), dimmed = dimPast && event.hasEnded(now),
modifier = animateItemMotion(), modifier = animateItemMotion(),
@@ -449,6 +453,7 @@ private fun AgendaEmptyDayRow(onClick: () -> Unit) {
@Composable @Composable
private fun AgendaEventRow( private fun AgendaEventRow(
event: EventInstance, event: EventInstance,
day: LocalDate,
position: Position, position: Position,
dimmed: Boolean, dimmed: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -460,7 +465,7 @@ private fun AgendaEventRow(
GroupedRow( GroupedRow(
modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier, modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier,
title = title, title = title,
summary = agendaTimeSummary(event), summary = agendaTimeSummary(event, day),
position = position, position = position,
minHeight = 64.dp, minHeight = 64.dp,
leading = { leading = {
@@ -554,16 +559,29 @@ private fun agendaDayLabel(date: LocalDate, today: LocalDate): String {
return if (relative != null) "$relative · $formatted" else formatted 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], 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 @Composable
private fun agendaTimeSummary(event: EventInstance): String { private fun agendaTimeSummary(event: EventInstance, day: LocalDate): String {
val time = if (event.isAllDay) { val is24Hour = LocalUse24HourFormat.current
stringResource(R.string.event_detail_all_day) val locale = currentLocale()
} else {
val is24Hour = LocalUse24HourFormat.current val time = when (val label = agendaTimeLabel(event, day, zone)) {
val locale = currentLocale() AgendaTimeLabel.AllDay -> stringResource(R.string.event_detail_all_day)
"${formatTime(event.start, is24Hour, locale)} ${formatTime(event.end, is24Hour, locale)}" is AgendaTimeLabel.Starts ->
stringResource(R.string.agenda_span_starts, formatTime(label.start, is24Hour, locale))
is AgendaTimeLabel.Ends ->
stringResource(R.string.agenda_span_ends, formatTime(label.end, is24Hour, locale))
is AgendaTimeLabel.Range ->
"${formatTime(label.start, is24Hour, locale)} ${formatTime(label.end, is24Hour, locale)}"
} }
val location = event.location?.takeIf { it.isNotBlank() } val location = event.location?.takeIf { it.isNotBlank() }
return if (location != null) "$time · $location" else time return if (location != null) "$time · $location" else time
} }

View File

@@ -2,9 +2,70 @@ package de.jeanlucmakiola.calendula.ui.agenda
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.FailureReason
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Instant
/**
* The zone the event's calendar dates live in. Timed events are resolved in the
* device [zone]; all-day events live at UTC midnights with an exclusive end, so
* resolving them anywhere but UTC shifts the boundaries — east of UTC that leaks
* a one-day event onto its next day. Matches the Week view and detail card.
*/
private fun EventInstance.dateZone(zone: TimeZone): TimeZone =
if (isAllDay) TimeZone.UTC else zone
/** The first calendar day this event occupies. */
fun EventInstance.spanFirstDay(zone: TimeZone): LocalDate =
start.toLocalDateTime(dateZone(zone)).date
/**
* The last calendar day this 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].
*/
fun EventInstance.spanLastDay(zone: TimeZone): LocalDate {
val lastInstant = if (end > start) end - 1.milliseconds else start
return lastInstant.toLocalDateTime(dateZone(zone)).date
}
/** Whether this event occupies more than one calendar day in [zone]. */
fun EventInstance.spansMultipleDays(zone: TimeZone): Boolean =
spanFirstDay(zone) != spanLastDay(zone)
/**
* What an agenda row's time line should convey for an event on a given day —
* the part of a multi-day span that [day] falls in. Pure and shared so the
* agenda screen and the agenda widget label multi-day events identically; each
* surface only formats the instants into its own locale/24h string.
*/
sealed interface AgendaTimeLabel {
/** An all-day event, or a whole in-between day of a multi-day span. */
data object AllDay : AgendaTimeLabel
/** The first day of a multi-day timed event: when it begins. */
data class Starts(val start: Instant) : AgendaTimeLabel
/** The last day of a multi-day timed event: when it ends. */
data class Ends(val end: Instant) : AgendaTimeLabel
/** A single-day timed event: its startend range. */
data class Range(val start: Instant, val end: Instant) : AgendaTimeLabel
}
/** The [AgendaTimeLabel] for [event] as it appears on [day], resolved in [zone]. */
fun agendaTimeLabel(event: EventInstance, day: LocalDate, zone: TimeZone): AgendaTimeLabel {
if (event.isAllDay) return AgendaTimeLabel.AllDay
val firstDay = event.spanFirstDay(zone)
val lastDay = event.spanLastDay(zone)
return when {
firstDay == lastDay -> AgendaTimeLabel.Range(event.start, event.end)
day <= firstDay -> AgendaTimeLabel.Starts(event.start)
day >= lastDay -> AgendaTimeLabel.Ends(event.end)
else -> AgendaTimeLabel.AllDay // a full in-between day
}
}
/** One calendar day with at least one event, for the agenda list. */ /** One calendar day with at least one event, for the agenda list. */
data class AgendaDay( data class AgendaDay(
@@ -15,31 +76,45 @@ data class AgendaDay(
/** /**
* Group flat [instances] into forward-looking [AgendaDay]s (only days that * Group flat [instances] into forward-looking [AgendaDay]s (only days that
* actually carry events). An event that began before [anchor] (ongoing or * actually carry events). A multi-day event surfaces on *every* day it spans,
* multi-day) is clamped to the anchor day so it still surfaces on top. Within a * not just its first — clamped to [[anchor], [windowEnd]] so an event that began
* day, all-day events sort first, then ascending by start time, then title. * before the window (ongoing) still lists from the anchor day, and one running
* past the window stops at the last visible day. Within a day, all-day events
* sort first, then ascending by start time, then title.
* *
* Shared by the Agenda screen and the agenda home-screen widget so both group * Shared by the Agenda screen and the agenda home-screen widget so both group
* and order identically. * and order identically.
*/ */
fun groupAgendaDays( fun groupAgendaDays(
anchor: LocalDate, anchor: LocalDate,
windowEnd: LocalDate,
instances: List<EventInstance>, instances: List<EventInstance>,
zone: TimeZone, zone: TimeZone,
): List<AgendaDay> = ): List<AgendaDay> {
instances val byDay = sortedMapOf<LocalDate, MutableList<EventInstance>>()
.groupBy { it.start.toLocalDateTime(zone).date.coerceAtLeast(anchor) } for (instance in instances) {
.toSortedMap() val firstDay = instance.spanFirstDay(zone).coerceAtLeast(anchor)
.map { (date, dayEvents) -> // Never below firstDay: an instance the query returned always surfaces on
AgendaDay( // at least its first visible day, even if its end resolves earlier (e.g. a
date = date, // zero-length or boundary instant) — otherwise the loop would drop it.
events = dayEvents.sortedWith( val lastDay = instance.spanLastDay(zone).coerceAtMost(windowEnd).coerceAtLeast(firstDay)
compareByDescending<EventInstance> { it.isAllDay } var day = firstDay
.thenBy { it.start } while (day <= lastDay) {
.thenBy { it.title }, byDay.getOrPut(day) { mutableListOf() }.add(instance)
), day = day.plus(1, DateTimeUnit.DAY)
)
} }
}
return byDay.map { (date, dayEvents) ->
AgendaDay(
date = date,
events = dayEvents.sortedWith(
compareByDescending<EventInstance> { it.isAllDay }
.thenBy { it.start }
.thenBy { it.title },
),
)
}
}
/** /**
* Ensure [today] surfaces as the first agenda day even when it carries no * Ensure [today] surfaces as the first agenda day even when it carries no

View File

@@ -159,11 +159,11 @@ class AgendaViewModel @Inject constructor(
return AgendaUiState.Failure(FailureReason.NoCalendarsConfigured) return AgendaUiState.Failure(FailureReason.NoCalendarsConfigured)
} }
val anchor = params.anchor val anchor = params.anchor
val days = groupAgendaDays(anchor, instances, zone)
val rangeEnd = anchor.plus( val rangeEnd = anchor.plus(
params.range.dayCount(anchor, params.weekStart) - 1, params.range.dayCount(anchor, params.weekStart) - 1,
DateTimeUnit.DAY, DateTimeUnit.DAY,
) )
val days = groupAgendaDays(anchor, rangeEnd, instances, zone)
return AgendaUiState.Success( return AgendaUiState.Success(
anchor = anchor, anchor = anchor,
today = todayDate, today = todayDate,

View File

@@ -131,13 +131,14 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
// the composition from Glance state, so changing the range is a plain // the composition from Glance state, so changing the range is a plain
// recomposition and never depends on the widget session restarting. // recomposition and never depends on the widget session restarting.
val window = agendaRange(anchor, AgendaRange.MAX_CUSTOM_DAYS - 1, zone) val window = agendaRange(anchor, AgendaRange.MAX_CUSTOM_DAYS - 1, zone)
val windowEnd = anchor.plus(AgendaRange.MAX_CUSTOM_DAYS - 1, DateTimeUnit.DAY)
val instances = ep.calendarRepository().instances(window).first() val instances = ep.calendarRepository().instances(window).first()
val is24Hour = prefs.timeFormat.first() val is24Hour = prefs.timeFormat.first()
.is24Hour(android.text.format.DateFormat.is24HourFormat(this)) .is24Hour(android.text.format.DateFormat.is24HourFormat(this))
val soften = prefs.softenCalendarColors.first() val soften = prefs.softenCalendarColors.first()
return AgendaWidgetData.Ready( return AgendaWidgetData.Ready(
today = anchor, today = anchor,
days = groupAgendaDays(anchor, instances, zone), days = groupAgendaDays(anchor, windowEnd, instances, zone),
is24Hour = is24Hour, is24Hour = is24Hour,
soften = soften, soften = soften,
weekStart = weekStart, weekStart = weekStart,

View File

@@ -49,6 +49,8 @@ import de.jeanlucmakiola.calendula.data.prefs.parsePastEventDisplay
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.hasEnded import de.jeanlucmakiola.calendula.domain.hasEnded
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.AgendaTimeLabel
import de.jeanlucmakiola.calendula.ui.agenda.agendaTimeLabel
import de.jeanlucmakiola.calendula.ui.agenda.anchorTodayIfMissing import de.jeanlucmakiola.calendula.ui.agenda.anchorTodayIfMissing
import de.jeanlucmakiola.calendula.ui.agenda.dayCount import de.jeanlucmakiola.calendula.ui.agenda.dayCount
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
@@ -127,7 +129,7 @@ class RefreshAgendaAction : ActionCallback {
/** Flat row model so the [LazyColumn] can mix day headers and events. */ /** Flat row model so the [LazyColumn] can mix day headers and events. */
private sealed interface AgendaRow { private sealed interface AgendaRow {
data class Header(val date: LocalDate, val today: LocalDate) : AgendaRow data class Header(val date: LocalDate, val today: LocalDate) : AgendaRow
data class Event(val event: EventInstance) : AgendaRow data class Event(val date: LocalDate, val event: EventInstance) : AgendaRow
/** "Nothing left today" line under an anchored, event-less today (#35). */ /** "Nothing left today" line under an anchored, event-less today (#35). */
data class Placeholder(val date: LocalDate) : AgendaRow data class Placeholder(val date: LocalDate) : AgendaRow
} }
@@ -185,7 +187,7 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
if (day.events.isEmpty()) { if (day.events.isEmpty()) {
add(AgendaRow.Placeholder(day.date)) add(AgendaRow.Placeholder(day.date))
} else { } else {
day.events.forEach { add(AgendaRow.Event(it)) } day.events.forEach { add(AgendaRow.Event(day.date, it)) }
} }
} }
} }
@@ -196,6 +198,7 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
is AgendaRow.Placeholder -> PlaceholderRow(row.date) is AgendaRow.Placeholder -> PlaceholderRow(row.date)
is AgendaRow.Event -> EventRow( is AgendaRow.Event -> EventRow(
event = row.event, event = row.event,
day = row.date,
dark = dark, dark = dark,
soften = data.soften, soften = data.soften,
is24Hour = data.is24Hour, is24Hour = data.is24Hour,
@@ -312,6 +315,7 @@ private fun PlaceholderRow(date: LocalDate) {
@Composable @Composable
private fun EventRow( private fun EventRow(
event: EventInstance, event: EventInstance,
day: LocalDate,
dark: Boolean, dark: Boolean,
soften: Boolean, soften: Boolean,
is24Hour: Boolean, is24Hour: Boolean,
@@ -357,7 +361,7 @@ private fun EventRow(
style = TextStyle(color = titleColor, fontSize = 14.sp), style = TextStyle(color = titleColor, fontSize = 14.sp),
) )
Text( Text(
text = eventTimeSummary(context, event, is24Hour), text = eventTimeSummary(context, event, day, is24Hour),
maxLines = 1, maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp), style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp),
) )
@@ -396,11 +400,20 @@ private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate):
return if (relative != null) "$relative · $formatted" else formatted return if (relative != null) "$relative · $formatted" else formatted
} }
private fun eventTimeSummary(context: Context, event: EventInstance, is24Hour: Boolean): String { private fun eventTimeSummary(
val time = if (event.isAllDay) { context: Context,
context.getString(R.string.event_detail_all_day) event: EventInstance,
} else { day: LocalDate,
"${formatTime(event.start, is24Hour)} ${formatTime(event.end, is24Hour)}" is24Hour: Boolean,
): String {
val time = when (val label = agendaTimeLabel(event, day, zone())) {
AgendaTimeLabel.AllDay -> context.getString(R.string.event_detail_all_day)
is AgendaTimeLabel.Starts ->
context.getString(R.string.agenda_span_starts, formatTime(label.start, is24Hour))
is AgendaTimeLabel.Ends ->
context.getString(R.string.agenda_span_ends, formatTime(label.end, is24Hour))
is AgendaTimeLabel.Range ->
"${formatTime(label.start, is24Hour)} ${formatTime(label.end, is24Hour)}"
} }
val location = event.location?.takeIf { it.isNotBlank() } val location = event.location?.takeIf { it.isNotBlank() }
return if (location != null) "$time · $location" else time return if (location != null) "$time · $location" else time

View File

@@ -270,6 +270,9 @@
<string name="agenda_header_tomorrow">Tomorrow</string> <string name="agenda_header_tomorrow">Tomorrow</string>
<string name="agenda_empty_title">You\'re all caught up</string> <string name="agenda_empty_title">You\'re all caught up</string>
<string name="agenda_no_more_today">No more events today</string> <string name="agenda_no_more_today">No more events today</string>
<!-- Time line for a multi-day event, on its first / last day (%1$s is a time, e.g. "14:00"). -->
<string name="agenda_span_starts">Starts %1$s</string>
<string name="agenda_span_ends">Ends %1$s</string>
<!-- Event search --> <!-- Event search -->
<string name="search_action">Search</string> <string name="search_action">Search</string>

View File

@@ -0,0 +1,76 @@
package de.jeanlucmakiola.calendula.ui.agenda
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import kotlin.time.Instant
import org.junit.jupiter.api.Test
class AgendaTimeLabelTest {
private val zone = TimeZone.UTC
private fun at(y: Int, mo: Int, d: Int, h: Int = 0, min: Int = 0): Instant =
LocalDateTime(y, mo, d, h, min).toInstant(zone)
private fun event(start: Instant, end: Instant, isAllDay: Boolean = false) = EventInstance(
instanceId = 1,
eventId = 1,
calendarId = 1,
title = "e",
start = start,
end = end,
isAllDay = isAllDay,
color = 0,
location = null,
)
private fun labelOn(y: Int, mo: Int, d: Int, event: EventInstance) =
agendaTimeLabel(event, LocalDate(y, mo, d), zone)
@Test
fun `a single-day timed event is a start-end range`() {
val e = event(at(2026, 7, 2, 12, 0), at(2026, 7, 2, 13, 0))
assertThat(labelOn(2026, 7, 2, e))
.isEqualTo(AgendaTimeLabel.Range(e.start, e.end))
}
@Test
fun `a single-day all-day event is all-day`() {
val e = event(at(2026, 7, 2), at(2026, 7, 3), isAllDay = true)
assertThat(labelOn(2026, 7, 2, e)).isEqualTo(AgendaTimeLabel.AllDay)
}
@Test
fun `a multi-day timed event names the start, middle, and end days`() {
val e = event(at(2026, 7, 1, 14, 0), at(2026, 7, 4, 10, 0))
assertThat(labelOn(2026, 7, 1, e)).isEqualTo(AgendaTimeLabel.Starts(e.start))
assertThat(labelOn(2026, 7, 2, e)).isEqualTo(AgendaTimeLabel.AllDay)
assertThat(labelOn(2026, 7, 3, e)).isEqualTo(AgendaTimeLabel.AllDay)
assertThat(labelOn(2026, 7, 4, e)).isEqualTo(AgendaTimeLabel.Ends(e.end))
}
@Test
fun `a multi-day all-day event is all-day on every day`() {
val e = event(at(2026, 7, 2), at(2026, 7, 5), isAllDay = true)
assertThat(labelOn(2026, 7, 2, e)).isEqualTo(AgendaTimeLabel.AllDay)
assertThat(labelOn(2026, 7, 3, e)).isEqualTo(AgendaTimeLabel.AllDay)
assertThat(labelOn(2026, 7, 4, e)).isEqualTo(AgendaTimeLabel.AllDay)
}
@Test
fun `an event begun before the shown day is not labelled as starting`() {
// Runs 29 Jun 09:00 → 2 Jul 09:00; on 1 Jul it is mid-span, on 2 Jul it ends.
val e = event(at(2026, 6, 29, 9, 0), at(2026, 7, 2, 9, 0))
assertThat(labelOn(2026, 7, 1, e)).isEqualTo(AgendaTimeLabel.AllDay)
assertThat(labelOn(2026, 7, 2, e)).isEqualTo(AgendaTimeLabel.Ends(e.end))
}
}

View File

@@ -0,0 +1,150 @@
package de.jeanlucmakiola.calendula.ui.agenda
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import kotlin.time.Instant
import org.junit.jupiter.api.Test
class GroupAgendaDaysTest {
private val zone = TimeZone.UTC
private val anchor = LocalDate(2026, 7, 1)
// A wide window so clamping to the window end is out of the way unless tested.
private val windowEnd = LocalDate(2026, 7, 31)
private fun at(y: Int, mo: Int, d: Int, h: Int = 0, min: Int = 0): Instant =
LocalDateTime(y, mo, d, h, min).toInstant(zone)
private fun event(
id: Long,
title: String,
start: Instant,
end: Instant,
isAllDay: Boolean = false,
) = EventInstance(
instanceId = id,
eventId = id,
calendarId = 1,
title = title,
start = start,
end = end,
isAllDay = isAllDay,
color = 0,
location = null,
)
private fun days(instances: List<EventInstance>) =
groupAgendaDays(anchor, windowEnd, instances, zone)
@Test
fun `a timed multi-day event lists on every day it spans`() {
val e = event(1, "trip", at(2026, 7, 1, 14, 0), at(2026, 7, 3, 10, 0))
val result = days(listOf(e))
assertThat(result.map { it.date }).containsExactly(
LocalDate(2026, 7, 1),
LocalDate(2026, 7, 2),
LocalDate(2026, 7, 3),
).inOrder()
result.forEach { assertThat(it.events).containsExactly(e) }
}
@Test
fun `an all-day multi-day event stops before its exclusive next-midnight end`() {
// 13 July inclusive: end is the exclusive midnight opening 4 July.
val e = event(1, "holiday", at(2026, 7, 1), at(2026, 7, 4), isAllDay = true)
val result = days(listOf(e))
assertThat(result.map { it.date }).containsExactly(
LocalDate(2026, 7, 1),
LocalDate(2026, 7, 2),
LocalDate(2026, 7, 3),
).inOrder()
}
@Test
fun `a single-day event lists exactly once`() {
val e = event(1, "lunch", at(2026, 7, 2, 12, 0), at(2026, 7, 2, 13, 0))
assertThat(days(listOf(e)).map { it.date })
.containsExactly(LocalDate(2026, 7, 2))
}
@Test
fun `an event ending exactly at midnight does not reach the next day`() {
val e = event(1, "late", at(2026, 7, 1, 22, 0), at(2026, 7, 2, 0, 0))
assertThat(days(listOf(e)).map { it.date })
.containsExactly(LocalDate(2026, 7, 1))
}
@Test
fun `an event begun before the anchor is clamped to the anchor day`() {
val e = event(1, "ongoing", at(2026, 6, 29, 9, 0), at(2026, 7, 2, 9, 0))
assertThat(days(listOf(e)).map { it.date }).containsExactly(
LocalDate(2026, 7, 1),
LocalDate(2026, 7, 2),
).inOrder()
}
@Test
fun `an event running past the window is clamped to the last visible day`() {
val narrowEnd = LocalDate(2026, 7, 2)
val e = event(1, "long", at(2026, 7, 1, 9, 0), at(2026, 7, 5, 9, 0))
val result = groupAgendaDays(anchor, narrowEnd, listOf(e), zone)
assertThat(result.map { it.date }).containsExactly(
LocalDate(2026, 7, 1),
LocalDate(2026, 7, 2),
).inOrder()
}
// All-day events are stored at UTC midnights; resolving them in a device
// zone east of UTC would leak them onto the following day (see spanLastDay).
private val berlin = TimeZone.of("Europe/Berlin")
private fun utcMidnight(y: Int, mo: Int, d: Int): Instant =
LocalDateTime(y, mo, d, 0, 0).toInstant(TimeZone.UTC)
@Test
fun `a single-day all-day event does not leak onto the next day in an eastern zone`() {
val e = event(1, "birthday", utcMidnight(2026, 7, 2), utcMidnight(2026, 7, 3), isAllDay = true)
val result = groupAgendaDays(anchor, windowEnd, listOf(e), berlin)
assertThat(result.map { it.date }).containsExactly(LocalDate(2026, 7, 2))
assertThat(e.spansMultipleDays(berlin)).isFalse()
}
@Test
fun `an all-day multi-day event spans its true days in an eastern zone`() {
// 24 July inclusive: exclusive end is the UTC midnight opening 5 July.
val e = event(1, "holiday", utcMidnight(2026, 7, 2), utcMidnight(2026, 7, 5), isAllDay = true)
val result = groupAgendaDays(anchor, windowEnd, listOf(e), berlin)
assertThat(result.map { it.date }).containsExactly(
LocalDate(2026, 7, 2),
LocalDate(2026, 7, 3),
LocalDate(2026, 7, 4),
).inOrder()
}
@Test
fun `within a day all-day events sort before timed ones`() {
val allDay = event(1, "birthday", at(2026, 7, 1), at(2026, 7, 2), isAllDay = true)
val timed = event(2, "call", at(2026, 7, 1, 9, 0), at(2026, 7, 1, 10, 0))
val dayOne = days(listOf(timed, allDay)).first { it.date == anchor }
assertThat(dayOne.events).containsExactly(allDay, timed).inOrder()
}
}