From 4d3859490bf45366faf9ebf17770c233bc94a446 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 6 Nov 2026 11:31:30 +0100 Subject: [PATCH] Keep short events at the end of the day fully visible (#483) (#485) Short events near midnight keep their minimum height flush against midnight and get their own lane, and the timeline scrolls far enough past midnight to clear the + button. --- CHANGELOG.md | 2 + .../calendula/ui/agenda/AgendaScreen.kt | 3 +- .../calendula/ui/common/CalendarFabColumn.kt | 3 + .../calendula/ui/common/HourGutter.kt | 6 +- .../calendula/ui/common/OutOfViewEdges.kt | 31 ++++---- .../calendula/ui/common/TimelineDrag.kt | 22 +++--- .../calendula/ui/common/TimelineHours.kt | 13 +++- .../calendula/ui/common/TimelineScale.kt | 36 ++++++++- .../calendula/ui/common/TimelineScroll.kt | 21 ++++-- .../calendula/ui/month/MonthScreen.kt | 9 ++- .../calendula/ui/week/WeekScreen.kt | 74 ++++++++++++------- .../calendula/ui/week/WeekUiState.kt | 3 + .../calendula/ui/week/WeekViewModel.kt | 32 ++++---- .../ui/common/DragPieceBoundsTest.kt | 7 ++ .../calendula/ui/common/OutOfViewEdgesTest.kt | 15 ++++ .../calendula/ui/common/RenderedSpanTest.kt | 52 +++++++++++++ .../ui/common/TimedBlockShapeTest.kt | 10 +-- .../calendula/ui/common/TimelineHoursTest.kt | 8 ++ .../calendula/ui/common/TimelineScaleTest.kt | 13 ++++ .../calendula/ui/week/WeekLayoutTest.kt | 36 +++++++++ 20 files changed, 306 insertions(+), 90 deletions(-) create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RenderedSpanTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ce08c3..d92ca74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Widgets show your changes straight away.** After you added, edited or deleted an event, a widget could keep showing the old events until something else redrew it ([#420], [#229]). - **Widgets follow the app's language.** After you picked a language in Settings, widgets kept the old one until something redrew them ([#90]). - **The month widget handles busy months.** It could show "Can't show content", or only one or two events per day. Each day now shows as many events as its height fits, and the rest fold into +N ([#372]). +- **Short events late in the day are fully visible.** An event like 23:58–23:59 used to be cut down to a sliver at the bottom of the day, week and 3-day views. It now keeps its usual minimum height and sits flush against midnight ([#483]). ## [2.23.0] — 2026-10-21 @@ -1922,3 +1923,4 @@ automatically, with zero telemetry and no internet permission. [#439]: https://codeberg.org/jlmakiola/calendula/issues/439 [#441]: https://codeberg.org/jlmakiola/calendula/issues/441 [#476]: https://codeberg.org/jlmakiola/calendula/issues/476 +[#483]: https://codeberg.org/jlmakiola/calendula/issues/483 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 1b2871e..7a55c64 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,5 +1,6 @@ package de.jeanlucmakiola.calendula.ui.agenda +import de.jeanlucmakiola.calendula.ui.common.FabClearance import de.jeanlucmakiola.calendula.ui.common.PublishChrome import de.jeanlucmakiola.calendula.ui.common.ScreenActions import de.jeanlucmakiola.calendula.ui.common.ScreenBody @@ -432,7 +433,7 @@ private fun AgendaList( modifier = modifier.nestedScroll(loadOnScrollUp), state = listState, // Bottom inset clears the FAB stack so the last row stays tappable. - contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp), + contentPadding = PaddingValues(top = 8.dp, bottom = FabClearance), ) { agendaDays( days = days, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarFabColumn.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarFabColumn.kt index 18a8fa6..af1b53f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarFabColumn.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarFabColumn.kt @@ -38,6 +38,9 @@ import de.jeanlucmakiola.calendula.R /** The standard FAB's size, shared by the selection bar that unrolls from it (#141). */ val FabSize = 56.dp +/** Bottom inset that lets a scrolling view's last row or hour scroll clear of the FAB. */ +val FabClearance = 96.dp + /** What the FAB is while an event is selected (#141): the toggle for its action bar. */ @Stable class FabSelection( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGutter.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGutter.kt index c7f0aef..d04e548 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGutter.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGutter.kt @@ -56,7 +56,8 @@ private const val DIMMED_HOUR_ALPHA = 0.3f * The timeline's hour gutter. Scrolls in sync with the day columns through the * shared [scrollState], and while [dragController] holds a lifted block it dims * the hour labels and floats a badge with the drag's current start time at the - * row the block would land on. + * row the block would land on. Its content ends in the same [FabClearance] + * the day columns do, or the two drift apart at the bottom of the scroll. */ @Composable fun HourGutter( @@ -86,7 +87,8 @@ fun HourGutter( .width(GUTTER_WIDTH) .padding(start = GUTTER_CONTENT_START_INSET) .fillMaxHeight() - .verticalScroll(scrollState), + .verticalScroll(scrollState) + .padding(bottom = FabClearance), ) { Column { (0 until 24).forEach { h -> diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/OutOfViewEdges.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/OutOfViewEdges.kt index 67cd722..19fe634 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/OutOfViewEdges.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/OutOfViewEdges.kt @@ -70,27 +70,24 @@ data class OutOfViewEvents(val above: TimedBlock?, val below: TimedBlock?) { /** Which edge of the viewport a hidden event sits past. */ enum class ViewportEdge { Top, Bottom } -/** Where [block]'s rendered bottom lands, in minutes — short blocks keep a floor. */ -internal fun renderedEndMin(block: TimedBlock): Float = - maxOf(block.endMin.toFloat(), block.startMin + MIN_EVENT_FRACTION * 60f) - /** * The blocks of one column lying out of a viewport spanning [topMin]..[bottomMin] * (minutes of the day), nearest first. A block leaves this set as soon as any - * part of its rendered height enters the viewport. + * part of its [renderedSpan] enters the viewport. */ fun outOfViewEvents( blocks: List, topMin: Float, bottomMin: Float, ): OutOfViewEvents { - val above = blocks - .filter { renderedEndMin(it) <= topMin } - .maxWithOrNull(compareBy({ renderedEndMin(it) }, { it.startMin })) - val below = blocks - .filter { it.startMin >= bottomMin } - .minWithOrNull(compareBy({ it.startMin }, { -renderedEndMin(it) })) - return OutOfViewEvents(above, below) + val spans = blocks.map { it to it.renderedSpan() } + val above = spans + .filter { (_, span) -> span.bottomMin <= topMin } + .maxWithOrNull(compareBy({ it.second.bottomMin }, { it.first.startMin })) + val below = spans + .filter { (_, span) -> span.topMin >= bottomMin } + .minWithOrNull(compareBy({ it.second.topMin }, { -it.second.bottomMin })) + return OutOfViewEvents(above?.first, below?.first) } /** @@ -107,8 +104,9 @@ fun revealScrollTarget( marginPx: Float, maxScroll: Int, ): Int { - val startPx = block.startMin / 60f * hourPx - val endPx = renderedEndMin(block) / 60f * hourPx + val span = block.renderedSpan() + val startPx = span.topMin / 60f * hourPx + val endPx = span.bottomMin / 60f * hourPx val target = when (edge) { ViewportEdge.Top -> startPx - marginPx ViewportEdge.Bottom -> minOf(endPx + marginPx - viewportPx, startPx - marginPx) @@ -293,6 +291,7 @@ private fun EdgeBar( modifier: Modifier = Modifier, ) { block ?: return + val span = remember(block) { block.renderedSpan() } val color = eventFill(block.event.color, dark, LocalSoftenColors.current) val label = stringResource( if (edge == ViewportEdge.Top) R.string.timeline_earlier_events else R.string.timeline_later_events, @@ -303,8 +302,8 @@ private fun EdgeBar( .height(EDGE_TOUCH_HEIGHT) .graphicsLayer { val gapPx = when (edge) { - ViewportEdge.Top -> scrollState.value - renderedEndMin(block) / 60f * hourPx - ViewportEdge.Bottom -> block.startMin / 60f * hourPx - + ViewportEdge.Top -> scrollState.value - span.bottomMin / 60f * hourPx + ViewportEdge.Bottom -> span.topMin / 60f * hourPx - (scrollState.value + scrollState.viewportSize) } alpha = edgeBarAlpha(gapPx, fadeLeadPx) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt index 3c0c6df..9ed6cf4 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt @@ -267,9 +267,10 @@ internal fun dragPieceBounds( ): DragPieceBounds { val top = if (slice.continuesBefore) 0f else insetPx val bottom = if (slice.continuesAfter) 0f else insetPx - val full = maxOf(slice.spanMin / 60f * hourPx, MIN_EVENT_FRACTION * hourPx) + val span = renderedSpan(slice.startMin, slice.startMin + slice.spanMin, slice.continuesAfter) + val full = (span.bottomMin - span.topMin) / 60f * hourPx return DragPieceBounds( - top = slice.startMin / 60f * hourPx + top, + top = span.topMin / 60f * hourPx + top, height = (full - top - bottom).coerceAtLeast(0f), ) } @@ -327,12 +328,13 @@ internal fun draftStartMin(minutes: Float): Int = internal fun draftBlock(draft: EventInstance, date: LocalDate, zone: TimeZone): TimedBlock? { // Clipped to the day the way the grid lays out events, so it's drawn the same across a DST change (#387). // The end is exclusive, so a draft ending at midnight belongs to the day before. + val dayEnd = date.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone) val start = maxOf(draft.start, date.atStartOfDayIn(zone)) - val end = minOf(draft.end, date.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone)) + val end = minOf(draft.end, dayEnd) if (end <= start) return null val startMin = wallClockMinutes(start, date, zone) val endMin = wallClockEndMinutes(startMin, start, end, date, zone).coerceAtMost(MINUTES_PER_DAY) - return TimedBlock(draft, startMin, endMin, lane = 0, laneCount = 1) + return TimedBlock(draft, startMin, endMin, lane = 0, laneCount = 1, continuesAfter = draft.end > dayEnd) } /** Which end of an event a resize handle moves (#141). */ @@ -903,7 +905,9 @@ class TimelineDragController { val edgeIndex = geometry.days.indexOf(span.day.plus(edgeDay, DateTimeUnit.DAY)) session.column = edgeIndex.takeIf { it >= 0 } ?.let { column(it) } - session.grabMin = (pointerResizeMinutes(session) ?: 0f) - + // The top handle sits on the drawn top, which a short block near midnight lifts (#483). + val lift = if (edge == ResizeEdge.Top) block.startMin - block.renderedSpan().topMin else 0f + session.grabMin = (pointerResizeMinutes(session) ?: 0f) + lift - if (edge == ResizeEdge.Top) session.startMin else session.endMin originSlot = null recompute() @@ -963,7 +967,9 @@ class TimelineDragController { session.column = held val dayOffset = (days[column(held)].toEpochDays() - session.day.toEpochDays()).toInt() session.edgeDay = dayOffset - return dayOffset * MINUTES_PER_DAY + (pointer.y - origin.y) / hourPx * 60f + // Held to its column's midnight: the clearance under it is not the next day. + val minutes = ((pointer.y - origin.y) / hourPx * 60f).coerceAtMost(MINUTES_PER_DAY.toFloat()) + return dayOffset * MINUTES_PER_DAY + minutes } private fun recomputeResize(session: ResizeSession) { @@ -1215,10 +1221,6 @@ fun TimedBlock.clipOffsetMinutes(day: LocalDate, zone: TimeZone): Int { fun TimedBlock.continuesBefore(day: LocalDate, zone: TimeZone): Boolean = event.start < day.atStartOfDayIn(zone) -/** True when the event runs past [day]'s midnight, so this block is cut there. */ -fun TimedBlock.continuesAfter(day: LocalDate, zone: TimeZone): Boolean = - event.end > day.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone) - /** * The instant a drop asks for — the *event's* new start, so the tail of an * overnight event drops back onto the day its own start belongs to. Read in the diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineHours.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineHours.kt index 56ab4c6..5632707 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineHours.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineHours.kt @@ -43,14 +43,21 @@ data class TimelineHours( /** * The scroll offset that puts [startHour] at the top of a timeline laid out - * with [maxValue] scroll range over a [viewportPx] viewport, or null while that + * with [maxValue] scroll range over a [viewportPx] viewport, [clearancePx] of + * which is the empty inset under midnight, or null while that * layout is not yet at [hourPx] per hour — the scale changes a frame before the * content grows to match it, and scrolling against the old layout would land on * the wrong hour. */ -internal fun startHourScroll(maxValue: Int, viewportPx: Int, startHour: Int, hourPx: Float): Int? { +internal fun startHourScroll( + maxValue: Int, + viewportPx: Int, + startHour: Int, + hourPx: Float, + clearancePx: Int = 0, +): Int? { if (viewportPx <= 0 || maxValue < 0 || maxValue == Int.MAX_VALUE) return null - val laidOutHourPx = (maxValue + viewportPx) / 24f + val laidOutHourPx = (maxValue + viewportPx - clearancePx) / 24f if (abs(laidOutHourPx - hourPx) > 1f) return null return (startHour * laidOutHourPx).roundToInt().coerceIn(0, maxValue) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineScale.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineScale.kt index 227e5f6..a45b291 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineScale.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineScale.kt @@ -4,6 +4,8 @@ import androidx.annotation.StringRes import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.ui.week.MINUTES_PER_DAY +import de.jeanlucmakiola.calendula.ui.week.TimedBlock /** * How tall one hour is drawn in the week and day timelines (#56). @@ -59,8 +61,9 @@ sealed interface TimelineScale { } /** - * Hour height for this scale. [viewportHeight] is the visible height of the - * scrolling timeline and is only consulted by [TimelineScale.FitDay]. + * Hour height for this scale. [viewportHeight] is the height the day is fitted + * to, the scrolling timeline's [dayFitHeight], and is not consulted by the + * fixed presets. * * The fit-day result is clamped: below [FIT_DAY_MIN] the 24 gutter labels stop * being legible, and above [FIT_DAY_MAX] a short landscape day would stretch its @@ -85,6 +88,13 @@ fun TimelineScale.hourHeight(viewportHeight: Dp): Dp = when (this) { .coerceAtLeast(fillHourHeight(viewportHeight)) } +/** + * The part of a [viewportHeight]-tall timeline the day is fitted to: the + * [FabClearance] under midnight, which lets the last hour scroll clear of the + * FAB (#483), is left out. Pass this, not the raw viewport, to [hourHeight]. + */ +fun dayFitHeight(viewportHeight: Dp): Dp = (viewportHeight - FabClearance).coerceAtLeast(0.dp) + /** * The minute of the day a tap [offsetY] px down a timeline column means, snapped * to the hour it landed in. @@ -105,6 +115,28 @@ fun tappedMinuteOfDay(offsetY: Float, hourPx: Float): Int = */ const val MIN_EVENT_FRACTION = 26f / 60f +/** Where a timed block is drawn down its day, in minutes from midnight. */ +data class RenderedSpan(val topMin: Float, val bottomMin: Float) + +/** + * Where a block of [startMin]..[endMin] is drawn, at least [MIN_EVENT_FRACTION] + * of an hour tall. A short block near midnight rises until that floor ends at + * midnight instead of running off the day, unless it [continuesAfter] into the + * next one (#483). + */ +fun renderedSpan(startMin: Int, endMin: Int, continuesAfter: Boolean): RenderedSpan { + val floor = MIN_EVENT_FRACTION * 60f + val bottom = maxOf(endMin.toFloat(), startMin + floor) + return if (continuesAfter || bottom <= MINUTES_PER_DAY) { + RenderedSpan(startMin.toFloat(), bottom) + } else { + RenderedSpan(MINUTES_PER_DAY - floor, MINUTES_PER_DAY.toFloat()) + } +} + +/** This block's [renderedSpan]. */ +fun TimedBlock.renderedSpan(): RenderedSpan = renderedSpan(startMin, endMin, continuesAfter) + /** Smallest hour height [TimelineScale.FitDay] will resolve to. */ val FIT_DAY_MIN = 24.dp diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineScroll.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineScroll.kt index 7282db7..f50505a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineScroll.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineScroll.kt @@ -32,16 +32,21 @@ val LocalTimelineScroll = staticCompositionLocalOf { null /** A timeline's scroll, and where it opens before it can scroll there. */ @Stable -class TimelineScroll(val state: ScrollState, noonPending: Boolean, private val openingHour: Int?) { +class TimelineScroll( + val state: ScrollState, + noonPending: Boolean, + private val openingHour: Int?, + private val clearancePx: Float = 0f, +) { /** Whether it is still to be put on noon, which it only can be once laid out. */ var noonPending by mutableStateOf(noonPending) internal set - /** The top of what it shows, or will once open, over [content] pixels in a [viewport] pixels high. */ + /** The top of what it shows, or will once open, over [content] pixels of day in a [viewport] pixels high. */ fun top(viewport: Float, content: Float): Float = if (noonPending) { (openingHour?.let { it * content / 24f } ?: (content - viewport) / 2f) - .coerceIn(0f, (content - viewport).coerceAtLeast(0f)) + .coerceIn(0f, (content + clearancePx - viewport).coerceAtLeast(0f)) } else state.value.toFloat() } @@ -58,6 +63,7 @@ fun rememberTimelineScrollState(timelineTop: Dp): TimelineScroll { val density = LocalDensity.current val zoom = LocalTimelineZoom.current val topPx = with(density) { timelineTop.toPx() } + val clearancePx = with(density) { FabClearance.roundToPx() } val remembered = memory?.midnightY val scrollState = rememberScrollState( initial = remembered?.let { (topPx - it).roundToInt().coerceAtLeast(0) } ?: 0, @@ -67,6 +73,7 @@ fun rememberTimelineScrollState(timelineTop: Dp): TimelineScroll { scrollState, noonPending = remembered == null, openingHour = zoom.hours.takeIf { it.enabled }?.startHour, + clearancePx = clearancePx.toFloat(), ) } val currentTop by rememberUpdatedState(topPx) @@ -80,20 +87,20 @@ fun rememberTimelineScrollState(timelineTop: Dp): TimelineScroll { val startHour = zoom.open() if (startHour == null) { snapshotFlow { scrollState.maxValue }.first { it > 0 } - scrollState.scrollTo(scrollState.maxValue / 2) + scrollState.scrollTo(((scrollState.maxValue - clearancePx) / 2).coerceAtLeast(0)) } else { // Fitted to the scroll viewport itself: the layout resolves scales // against the viewport plus the all-day strip, which would push the // end hour below the screen. val viewport = snapshotFlow { scrollState.viewportSize }.first { it > 0 } - val hourHeight = zoom.scale.hourHeight(with(density) { viewport.toDp() }) + val hourHeight = zoom.scale.hourHeight(dayFitHeight(with(density) { viewport.toDp() })) zoom.holdFit(hourHeight) val hourPx = with(density) { hourHeight.toPx() } val target = withTimeoutOrNull(FIT_LAYOUT_TIMEOUT_MILLIS) { snapshotFlow { - startHourScroll(scrollState.maxValue, scrollState.viewportSize, startHour, hourPx) + startHourScroll(scrollState.maxValue, scrollState.viewportSize, startHour, hourPx, clearancePx) }.filterNotNull().first() - } ?: (startHour * (scrollState.maxValue + scrollState.viewportSize) / 24) + } ?: (startHour * (scrollState.maxValue + scrollState.viewportSize - clearancePx) / 24) .coerceIn(0, scrollState.maxValue.coerceAtLeast(0)) scrollState.scrollTo(target) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index 807aeab..c5d666e 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.runtime.setValue import androidx.compose.runtime.rememberUpdatedState import de.jeanlucmakiola.calendula.ui.agenda.agendaDays +import de.jeanlucmakiola.calendula.ui.common.FabClearance import de.jeanlucmakiola.calendula.ui.common.PublishChrome import de.jeanlucmakiola.calendula.ui.common.ScreenActions import de.jeanlucmakiola.calendula.ui.common.ScreenBody @@ -965,7 +966,7 @@ internal fun ContinuousMonthGrid( state = listState, modifier = modifier.fillMaxSize(), // Bottom inset clears the FAB stack so the last row stays tappable. - contentPadding = PaddingValues(bottom = 96.dp), + contentPadding = PaddingValues(bottom = FabClearance), ) { repeat(monthCount) { index -> val month = yearMonthForIndex(index) @@ -1111,7 +1112,7 @@ internal fun DenseMonthGrid( .padding(top = DENSE_HEADER_GAP), verticalArrangement = Arrangement.spacedBy(2.dp), // Bottom inset clears the FAB stack so the last row stays tappable. - contentPadding = PaddingValues(bottom = 96.dp), + contentPadding = PaddingValues(bottom = FabClearance), ) { items(count = weekCount, key = { it }) { index -> val week = state.weeksByIndex[index] @@ -1893,7 +1894,7 @@ private fun SplitMonthAgendaPane( } val dimCutoff = LocalDimCutoff.current val listState = key(state.month) { rememberLazyListState() } - LazyColumn(state = listState, contentPadding = PaddingValues(bottom = 96.dp)) { + LazyColumn(state = listState, contentPadding = PaddingValues(bottom = FabClearance)) { // A fixed first item, so a day added above the first listed one shows // up rather than landing above a list that holds its scroll position. item(key = "top") { Spacer(Modifier.height(8.dp)) } @@ -1968,7 +1969,7 @@ internal fun SplitDayPane( else -> LazyColumn( // Bottom inset clears the FAB stack so the last row stays // tappable. - contentPadding = PaddingValues(bottom = 96.dp), + contentPadding = PaddingValues(bottom = FabClearance), ) { itemsIndexed( items = dayEvents, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index fa86813..72c0999 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -115,6 +115,7 @@ import androidx.compose.ui.layout.ParentDataModifier import androidx.compose.ui.unit.Density import androidx.compose.ui.layout.layout import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity @@ -161,7 +162,6 @@ import de.jeanlucmakiola.calendula.ui.common.StripDrop import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay import de.jeanlucmakiola.calendula.ui.common.TimelineDrop import de.jeanlucmakiola.calendula.ui.common.clipOffsetMinutes -import de.jeanlucmakiola.calendula.ui.common.continuesAfter import de.jeanlucmakiola.calendula.ui.common.continuesBefore import de.jeanlucmakiola.calendula.ui.common.ChipCuts import de.jeanlucmakiola.calendula.ui.common.timedBlockCuts @@ -196,8 +196,10 @@ import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.LocalShowHourGrid import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom -import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION +import de.jeanlucmakiola.calendula.ui.common.renderedSpan import de.jeanlucmakiola.calendula.ui.common.hourHeight +import de.jeanlucmakiola.calendula.ui.common.dayFitHeight +import de.jeanlucmakiola.calendula.ui.common.FabClearance import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay import de.jeanlucmakiola.calendula.ui.common.GUTTER_CONTENT_START_INSET @@ -944,8 +946,9 @@ private fun WeekFrame( ), ) { val zoom = LocalTimelineZoom.current - val fitHeight = if (animatedAllDayHeight == null) maxHeight + allDayHeight - else maxHeight + val fitHeight = dayFitHeight( + if (animatedAllDayHeight == null) maxHeight + allDayHeight else maxHeight, + ) val hourHeight = zoom.scale.hourHeight(fitHeight) HourGutter( scrollState = actions.scrollState, @@ -1383,7 +1386,9 @@ private fun Timeline( else Modifier.stableMorphViewport(animatedAllDayHeight, fullViewportHeight), ), ) { - val fitHeight = if (animatedAllDayHeight == null) maxHeight + allDayHeight else maxHeight + val fitHeight = dayFitHeight( + if (animatedAllDayHeight == null) maxHeight + allDayHeight else maxHeight, + ) val hourHeight = zoom.scale.hourHeight(fitHeight) val totalHeight = hourHeight * 24 // The pinch sits above the scroll viewport: it has to outrank the @@ -1416,9 +1421,11 @@ private fun Timeline( .fillMaxSize() .then(pinch) .padding(end = TIMELINE_CONTENT_END_INSET) - .clip(RoundedCornerShape(16.dp)) + .clip(RoundedCornerShape(TIMELINE_CORNER)) .verticalScroll(scrollState) - .onGloballyPositioned { if (active) dragController.geometry.viewport = it }, + .onGloballyPositioned { if (active) dragController.geometry.viewport = it } + // Past midnight, outside the columns: nothing there takes a tap or a drop. + .padding(bottom = FabClearance), ) { TimelineColumns( geometry = geometry, @@ -1454,6 +1461,7 @@ private fun Timeline( visibleTop = visibleTop, viewportHeight = visibleViewport, actions = actions, + shape = columnShape(day == geometry.dates.first(), day == geometry.dates.last()), modifier = Modifier.fillMaxHeight(), ) } @@ -1461,6 +1469,19 @@ private fun Timeline( } } +/** + * The outer columns round their bottom corner at midnight: the room left below + * for the buttons means the rounded window no longer does it there. + */ +private fun columnShape(first: Boolean, last: Boolean): Shape = + if (!first && !last) RectangleShape + else RoundedCornerShape( + bottomStart = if (first) TIMELINE_CORNER else 0.dp, + bottomEnd = if (last) TIMELINE_CORNER else 0.dp, + ) + +private val TIMELINE_CORNER = 16.dp + @Composable private fun DayColumnCard( blocks: List, @@ -1472,6 +1493,7 @@ private fun DayColumnCard( visibleTop: () -> Float, viewportHeight: () -> Float, actions: TimelineActions, + shape: Shape = RectangleShape, modifier: Modifier = Modifier, ) { val scrollState = actions.scrollState @@ -1502,8 +1524,8 @@ private fun DayColumnCard( val draftSurface = rememberDraftSurface(date, edges, hourPx, dragController, actions.onDrop) Card( // Plain rectangular columns — the soft corners come from the outer - // rounded scroll viewport, so inner rounding would look odd at the edges. - shape = RectangleShape, + // rounded scroll viewport, apart from the outer ones' bottoms ([columnShape]). + shape = shape, colors = CardDefaults.cardColors( // With the grid on, the container colour moves onto the hour cells // and the column behind them recedes to surface, so the gap between @@ -1543,7 +1565,6 @@ private fun DayColumnCard( // block or handle on top can claim the press first. .then(draftSurface), ) { - val minEventHeight = hourHeight * MIN_EVENT_FRACTION // Keyed by event, so a block that changes time or lane is the *same* // composable afterwards and tweens there. The ordinal disambiguates // a column holding two occurrences of one series. @@ -1551,9 +1572,17 @@ private fun DayColumnCard( shown.forEach { block -> val ordinal = ordinals.merge(block.event.eventId, 1, Int::plus)!! - 1 key(block.event.eventId, ordinal) { - val top = hourHeight * (block.startMin / 60f) - val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f) - val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight + // Where this block is cut at midnight, which decides both + // its shape and the half-gap it gives up to the hour cells. + val cuts = remember(block, date, zone) { + timedBlockCuts( + block.continuesBefore(date, zone), + block.continuesAfter, + ) + } + val span = remember(block) { block.renderedSpan() } + val top = hourHeight * (span.topMin / 60f) + val height = hourHeight * ((span.bottomMin - span.topMin) / 60f) val place = animatedBlockPlacement( xFraction = block.lane.toFloat() / block.laneCount, widthFraction = 1f / block.laneCount, @@ -1561,14 +1590,6 @@ private fun DayColumnCard( height = height, landing = dragController.ghosts(block), ) - // Where this block is cut at midnight, which decides both - // its shape and the half-gap it gives up to the hour cells. - val cuts = remember(block, date, zone) { - timedBlockCuts( - block.continuesBefore(date, zone), - block.continuesAfter(date, zone), - ) - } val topInset = hourCellBlockInset(showHourGrid, cuts.top) val bottomInset = hourCellBlockInset(showHourGrid, cuts.bottom) val blockHeight = (place.height - topInset - bottomInset) @@ -1904,7 +1925,7 @@ private fun rememberDraftSurface( block = block, zone = zone, pointerInRoot = pointer, - blockInRoot = node + Offset(0f, startMin / 60f * hour), + blockInRoot = node + Offset(0f, block.renderedSpan().topMin / 60f * hour), ) true }, @@ -1952,8 +1973,10 @@ private fun WeekLoading(dayCount: Int) { BoxWithConstraints(modifier = Modifier.fillMaxSize()) { // Same scale resolution as the loaded timeline, so the skeleton's // columns don't resize the moment the real week arrives. - val fitHeight = maxHeight + SKELETON_HEADER_HEIGHT + SKELETON_HEADER_PADDING * 2 - - headerHeight - TIMELINE_TOP_GAP + val fitHeight = dayFitHeight( + maxHeight + SKELETON_HEADER_HEIGHT + SKELETON_HEADER_PADDING * 2 - + headerHeight - TIMELINE_TOP_GAP, + ) val hourHeight = scale.hourHeight(fitHeight) val totalHeight = hourHeight * 24 // The skeleton wears the loaded columns' own ground, so the arrival @@ -1966,7 +1989,8 @@ private fun WeekLoading(dayCount: Int) { modifier = Modifier .fillMaxSize() .padding(end = TIMELINE_CONTENT_END_INSET) - .verticalScroll(scrollState), + .verticalScroll(scrollState) + .padding(bottom = FabClearance), ) { Spacer(Modifier.width(GUTTER_WIDTH)) repeat(dayCount) { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekUiState.kt index d4a5993..331c8c6 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekUiState.kt @@ -15,6 +15,8 @@ import kotlinx.datetime.LocalDate * minimum tap-target height) * @param lane 0-based column within [laneCount] * @param laneCount number of columns the event's overlap-cluster needs + * @param continuesAfter whether the event runs past this day's midnight, so the + * block is cut there */ data class TimedBlock( val event: EventInstance, @@ -22,6 +24,7 @@ data class TimedBlock( val endMin: Int, val lane: Int, val laneCount: Int, + val continuesAfter: Boolean = false, ) /** diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt index 90015f3..9a34d71 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt @@ -1,6 +1,7 @@ package de.jeanlucmakiola.calendula.ui.week import de.jeanlucmakiola.calendula.ui.common.dayRange +import de.jeanlucmakiola.calendula.ui.common.renderedSpan import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel @@ -439,6 +440,8 @@ internal fun List.splitWholeDays(zone: TimeZone): Pair if (ev.start == ev.end) { // Instant event: keep only if the point falls inside this day. if (ev.start < dayStart || ev.start >= dayEnd) return@mapNotNull null val m = wallClockMinutes(ev.start, day, zone) - Raw(ev, m, m) + TimedBlock(ev, m, m, lane = 0, laneCount = 1) } else { val s = maxOf(ev.start, dayStart) val e = minOf(ev.end, dayEnd) if (e <= s) return@mapNotNull null val startMin = wallClockMinutes(s, day, zone) val endMin = wallClockEndMinutes(startMin, s, e, day, zone).coerceAtMost(MINUTES_PER_DAY) - Raw(ev, startMin, endMin) + TimedBlock(ev, startMin, endMin, lane = 0, laneCount = 1, continuesAfter = ev.end > dayEnd) } } - .sortedWith(compareBy({ it.startMin }, { it.endMin })) + .map { it to it.renderedSpan() } + .sortedWith(compareBy({ it.second.topMin }, { it.second.bottomMin })) .toList() val result = ArrayList(raw.size) var i = 0 while (i < raw.size) { // Grow a cluster of chained-overlapping events. - var clusterEnd = raw[i].endMin + var clusterEnd = raw[i].second.bottomMin var j = i + 1 - while (j < raw.size && raw[j].startMin < clusterEnd) { - clusterEnd = maxOf(clusterEnd, raw[j].endMin) + while (j < raw.size && raw[j].second.topMin < clusterEnd) { + clusterEnd = maxOf(clusterEnd, raw[j].second.bottomMin) j++ } val cluster = raw.subList(i, j) // Greedy first-fit column assignment (= max overlap depth in the cluster). - val laneEnd = ArrayList() + val laneEnd = ArrayList() val lanes = IntArray(cluster.size) - cluster.forEachIndexed { k, r -> - var placed = laneEnd.indexOfFirst { it <= r.startMin } + cluster.forEachIndexed { k, (_, span) -> + var placed = laneEnd.indexOfFirst { it <= span.topMin } if (placed == -1) { - laneEnd.add(r.endMin) + laneEnd.add(span.bottomMin) placed = laneEnd.size - 1 } else { - laneEnd[placed] = r.endMin + laneEnd[placed] = span.bottomMin } lanes[k] = placed } val laneCount = laneEnd.size - cluster.forEachIndexed { k, r -> - result.add(TimedBlock(r.event, r.startMin, r.endMin, lanes[k], laneCount)) + cluster.forEachIndexed { k, (block, _) -> + result.add(block.copy(lane = lanes[k], laneCount = laneCount)) } i = j } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/DragPieceBoundsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/DragPieceBoundsTest.kt index 5a5777c..398af50 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/DragPieceBoundsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/DragPieceBoundsTest.kt @@ -72,6 +72,13 @@ class DragPieceBoundsTest { assertThat(bounds.height).isEqualTo(MIN_EVENT_FRACTION * hourPx - insetPx * 2) } + @Test + fun `a short piece at the end of the day rises as its block does`() { + val bounds = dragPieceBounds(slice(startMin = 1438, spanMin = 1), hourPx, insetPx = 0f) + assertThat(bounds.top + bounds.height).isWithin(0.01f).of(24 * hourPx) + assertThat(bounds.height).isWithin(0.01f).of(MIN_EVENT_FRACTION * hourPx) + } + @Test fun `an inset taller than the piece leaves no height at all`() { val bounds = dragPieceBounds(slice(startMin = 0, spanMin = 60), hourPx = 4f, insetPx = insetPx) diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/OutOfViewEdgesTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/OutOfViewEdgesTest.kt index 7a4dbe7..e0f7115 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/OutOfViewEdgesTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/OutOfViewEdgesTest.kt @@ -78,6 +78,21 @@ class OutOfViewEdgesTest { assertThat(outOf(blip).above).isNull() } + @Test + fun `a short event at midnight is judged by where it rises to`() { + // 23:58–23:59 draws from 23:34, so a viewport ending at 23:40 already shows it. + val late = block(6, 23 * 60 + 58, 23 * 60 + 59) + assertThat(outOfViewEvents(listOf(late), topMin = 1000f, bottomMin = 1420f).below).isNull() + val target = revealScrollTarget(late, ViewportEdge.Bottom, hourPx = 60f, viewportPx = 10, marginPx = 0f, maxScroll = 1440) + assertThat(target).isEqualTo(1440 - 26) + } + + @Test + fun `a short event carrying on past midnight is judged by its own start`() { + val overnight = block(7, 23 * 60 + 58, 24 * 60).copy(continuesAfter = true) + assertThat(outOfViewEvents(listOf(overnight), topMin = 1000f, bottomMin = 1420f).below).isEqualTo(overnight) + } + @Test fun `an event above scrolls to its start less the margin`() { val target = revealScrollTarget(breakfast, ViewportEdge.Top, hourPx = 100f, viewportPx = 700, marginPx = 20f, maxScroll = 1700) diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RenderedSpanTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RenderedSpanTest.kt new file mode 100644 index 0000000..e575263 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RenderedSpanTest.kt @@ -0,0 +1,52 @@ +package de.jeanlucmakiola.calendula.ui.common + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +/** A short block near midnight keeps its whole floor rather than running off the day (#483). */ +class RenderedSpanTest { + + private val floor = MIN_EVENT_FRACTION * 60f + + @Test + fun `a long block draws at its own times`() { + assertThat(renderedSpan(540, 600, continuesAfter = false)).isEqualTo(RenderedSpan(540f, 600f)) + } + + @Test + fun `a short block in the day is floored downwards`() { + val span = renderedSpan(20 * 60 + 58, 20 * 60 + 59, continuesAfter = false) + assertThat(span).isEqualTo(RenderedSpan(1258f, 1258f + floor)) + } + + @Test + fun `a short block at the end of the day rises to end at midnight`() { + val span = renderedSpan(23 * 60 + 58, 23 * 60 + 59, continuesAfter = false) + assertThat(span).isEqualTo(RenderedSpan(1440f - floor, 1440f)) + } + + @Test + fun `a block ending at midnight rises too`() { + val span = renderedSpan(23 * 60 + 59, 1440, continuesAfter = false) + assertThat(span).isEqualTo(RenderedSpan(1440f - floor, 1440f)) + } + + @Test + fun `an instant event at the end of the day rises`() { + val span = renderedSpan(1439, 1439, continuesAfter = false) + assertThat(span.bottomMin).isEqualTo(1440f) + assertThat(span.bottomMin - span.topMin).isEqualTo(floor) + } + + @Test + fun `a block that just fits stays put`() { + val start = 1440 - 26 + assertThat(renderedSpan(start, start + 5, continuesAfter = false)).isEqualTo(RenderedSpan(start.toFloat(), 1440f)) + } + + @Test + fun `a block carrying on past midnight keeps its start`() { + val span = renderedSpan(23 * 60 + 58, 1440, continuesAfter = true) + assertThat(span.topMin).isEqualTo(1438f) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimedBlockShapeTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimedBlockShapeTest.kt index 3a78179..b70fcf0 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimedBlockShapeTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimedBlockShapeTest.kt @@ -37,7 +37,7 @@ class TimedBlockShapeTest { val block = layoutDay(listOf(ev), day1, zone).single() assertThat(block.continuesBefore(day1, zone)).isFalse() - assertThat(block.continuesAfter(day1, zone)).isFalse() + assertThat(block.continuesAfter).isFalse() } @Test @@ -46,11 +46,11 @@ class TimedBlockShapeTest { val head = layoutDay(listOf(ev), day1, zone).single() assertThat(head.continuesBefore(day1, zone)).isFalse() - assertThat(head.continuesAfter(day1, zone)).isTrue() + assertThat(head.continuesAfter).isTrue() val tail = layoutDay(listOf(ev), day2, zone).single() assertThat(tail.continuesBefore(day2, zone)).isTrue() - assertThat(tail.continuesAfter(day2, zone)).isFalse() + assertThat(tail.continuesAfter).isFalse() } @Test @@ -59,7 +59,7 @@ class TimedBlockShapeTest { val middle = layoutDay(listOf(ev), day2, zone).single() assertThat(middle.continuesBefore(day2, zone)).isTrue() - assertThat(middle.continuesAfter(day2, zone)).isTrue() + assertThat(middle.continuesAfter).isTrue() } @Test @@ -67,7 +67,7 @@ class TimedBlockShapeTest { val ev = event(at(day1, 20), at(day2, 0)) val block = layoutDay(listOf(ev), day1, zone).single() - assertThat(block.continuesAfter(day1, zone)).isFalse() + assertThat(block.continuesAfter).isFalse() } @Test diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineHoursTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineHoursTest.kt index f7c0610..53d493b 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineHoursTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineHoursTest.kt @@ -45,6 +45,14 @@ class TimelineHoursTest { .isEqualTo(280) } + @Test + fun `the clearance under midnight is not counted as day`() { + // 40px an hour plus 100px of clearance over a 600px viewport: 460px of scroll. + assertThat( + startHourScroll(maxValue = 460, viewportPx = 600, startHour = 7, hourPx = 40f, clearancePx = 100), + ).isEqualTo(280) + } + @Test fun `a layout still at the previous scale is waited out`() { // Still 56px an hour: 1344 - 600 = 744 of scroll. diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineScaleTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineScaleTest.kt index 15b1e19..456419d 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineScaleTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineScaleTest.kt @@ -40,6 +40,19 @@ class TimelineScaleTest { assertThat(h * 24).isGreaterThan(phoneViewport * 0.9f) } + @Test + fun `fit-day leaves the FAB clearance under midnight`() { + val viewport = 800.dp + val h = TimelineScale.FitDay.hourHeight(dayFitHeight(viewport)) + assertThat(h * 24 + FabClearance).isAtMost(viewport) + assertThat(h * 24 + FabClearance).isGreaterThan(viewport * 0.99f) + } + + @Test + fun `the fitted height never goes negative`() { + assertThat(dayFitHeight(40.dp)).isEqualTo(0.dp) + } + @Test fun `fit-day clamps instead of shrinking hours past legibility`() { // A very short viewport (split screen, tiny device) would otherwise give diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekLayoutTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekLayoutTest.kt index 0fc4980..7b61872 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekLayoutTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekLayoutTest.kt @@ -108,6 +108,42 @@ class WeekLayoutTest { assertThat(blocks.all { it.lane == 0 && it.laneCount == 1 }).isTrue() } + @Test + fun `two short entries near midnight sit side by side`() { + // Both lift to end at midnight, so on real times alone the later one would hide the earlier (#483). + val a = event(at(wed, 23, 40), at(wed, 23, 41), id = 1L) + val b = event(at(wed, 23, 55), at(wed, 23, 56), id = 2L) + val blocks = layoutDay(listOf(a, b), wed, zone).sortedBy { it.lane } + assertThat(blocks.map { it.event.instanceId }).containsExactly(1L, 2L).inOrder() + assertThat(blocks.all { it.laneCount == 2 }).isTrue() + } + + @Test + fun `a short entry lifted into the event before it takes its own lane`() { + val a = event(at(wed, 23), at(wed, 23, 45), id = 1L) + val b = event(at(wed, 23, 58), at(wed, 23, 59), id = 2L) + val blocks = layoutDay(listOf(a, b), wed, zone) + assertThat(blocks.map { it.lane }).containsExactly(0, 1) + assertThat(blocks.all { it.laneCount == 2 }).isTrue() + } + + @Test + fun `a short entry clear of the next one keeps a single lane`() { + val a = event(at(wed, 9), at(wed, 9, 10), id = 1L) + val b = event(at(wed, 9, 30), at(wed, 10), id = 2L) + val blocks = layoutDay(listOf(a, b), wed, zone) + assertThat(blocks.all { it.lane == 0 && it.laneCount == 1 }).isTrue() + } + + @Test + fun `a block knows whether its event carries on past midnight`() { + val overnight = event(at(wed, 23), at(wed.plusDays(1), 1)) + assertThat(layoutDay(listOf(overnight), wed, zone).single().continuesAfter).isTrue() + assertThat(layoutDay(listOf(overnight), wed.plusDays(1), zone).single().continuesAfter).isFalse() + val toMidnight = event(at(wed, 23), at(wed.plusDays(1), 0)) + assertThat(layoutDay(listOf(toMidnight), wed, zone).single().continuesAfter).isFalse() + } + @Test fun `event spanning midnight is clipped to the day`() { // Starts the previous evening, ends 02:00 on wed.