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
This commit was merged in pull request #83.
This commit is contained in:
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 start–end 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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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`() {
|
||||||
|
// 1–3 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`() {
|
||||||
|
// 2–4 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user