Compare commits

..
18 changed files with 38 additions and 185 deletions

No files matched your search

@@ -32,8 +32,7 @@ import java.util.Locale
* - all-day, multi-day: "Tomorrow – 12 Jun 2026" (relative start, explicit end)
*
* All-day instances store UTC midnights with an exclusive end, so they are read
* in UTC and the end day is the last *covered* day (issue #308). A timed event
* from local midnight to local midnight reads like one too, in [zone] (#296).
* in UTC and the end day is the last *covered* day (issue #308).
*/
fun reminderTimeText(
beginMillis: Long,
@@ -48,14 +47,11 @@ fun reminderTimeText(
tomorrowLabel: String,
yesterdayLabel: String,
): String {
val wholeDays = !isAllDay && endMillis > beginMillis &&
isStartOfDay(beginMillis, zone) && isStartOfDay(endMillis, zone)
if (isAllDay || wholeDays) {
val dayZone = if (isAllDay) ZoneOffset.UTC else zone
if (isAllDay) {
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
// (atZone().toLocalDate() instead of LocalDate.ofInstant — API 34+)
val firstDay = Instant.ofEpochMilli(beginMillis).atZone(dayZone).toLocalDate()
val lastDay = Instant.ofEpochMilli(endMillis).atZone(dayZone).toLocalDate()
val firstDay = Instant.ofEpochMilli(beginMillis).atZone(ZoneOffset.UTC).toLocalDate()
val lastDay = Instant.ofEpochMilli(endMillis).atZone(ZoneOffset.UTC).toLocalDate()
.minusDays(1)
.coerceAtLeast(firstDay)
val start = relativeDayPrefix(
@@ -138,8 +134,3 @@ private fun LocalDate.previousOrSame(target: DayOfWeek): LocalDate {
}
private const val RANGE = " – "
private fun isStartOfDay(millis: Long, zone: ZoneId): Boolean {
val at = Instant.ofEpochMilli(millis).atZone(zone)
return at.toLocalDate().atStartOfDay(zone).toInstant().toEpochMilli() == millis
}
@@ -6,20 +6,13 @@ import kotlinx.datetime.TimeZone
fun EventInstance.displayPriority(zone: TimeZone): Int = when {
isCrossedOut -> 3
spansMultipleDays(zone) -> 0
coversWholeDays(zone) -> 1
isAllDay -> 1
else -> 2
}
/**
* Shared order for agenda and split day lists. Grid bars add their lane geometry.
* Each event's priority is worked out once, not on every comparison.
*/
fun List<EventInstance>.sortedForDisplay(zone: TimeZone): List<EventInstance> =
map { it to it.displayPriority(zone) }
.sortedWith(
compareBy<Pair<EventInstance, Int>> { it.second }
.thenBy { it.first.start }
.thenBy { it.first.title }
.thenBy { it.first.instanceId },
)
.map { it.first }
/** Shared order for agenda and split day lists. Grid bars add their lane geometry. */
fun eventDisplayComparator(zone: TimeZone): Comparator<EventInstance> =
compareBy<EventInstance> { it.displayPriority(zone) }
.thenBy { it.start }
.thenBy { it.title }
.thenBy { it.instanceId }
@@ -150,17 +150,6 @@ fun EventInstance.spanLastDay(zone: TimeZone): LocalDate {
fun EventInstance.spansMultipleDays(zone: TimeZone): Boolean =
spanFirstDay(zone) != spanLastDay(zone)
/**
* Whether this event fills whole days in [zone], so the grids and lists lay it
* out with the all-day events: an all-day event, or a timed one running from
* local midnight to local midnight (#296). The event itself stays timed.
*/
fun EventInstance.coversWholeDays(zone: TimeZone): Boolean =
isAllDay || (end > start && start.isStartOfDay(zone) && end.isStartOfDay(zone))
private fun Instant.isStartOfDay(zone: TimeZone): Boolean =
this == toLocalDateTime(zone).date.atStartOfDayIn(zone)
data class EventDetail(
val instance: EventInstance,
val description: String?,
@@ -1,8 +1,7 @@
package de.jeanlucmakiola.calendula.ui.agenda
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.coversWholeDays
import de.jeanlucmakiola.calendula.domain.sortedForDisplay
import de.jeanlucmakiola.calendula.domain.eventDisplayComparator
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.spanFirstDay
import de.jeanlucmakiola.calendula.domain.spanLastDay
@@ -19,7 +18,7 @@ import kotlin.time.Instant
* surface only formats the instants into its own locale/24h string.
*/
sealed interface AgendaTimeLabel {
/** An event filling whole days (#296), or a whole in-between day of a multi-day span. */
/** 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
@@ -31,7 +30,7 @@ sealed interface AgendaTimeLabel {
/** The [AgendaTimeLabel] for [event] as it appears on [day], resolved in [zone]. */
fun agendaTimeLabel(event: EventInstance, day: LocalDate, zone: TimeZone): AgendaTimeLabel {
if (event.coversWholeDays(zone)) return AgendaTimeLabel.AllDay
if (event.isAllDay) return AgendaTimeLabel.AllDay
val firstDay = event.spanFirstDay(zone)
val lastDay = event.spanLastDay(zone)
return when {
@@ -88,7 +87,7 @@ fun groupAgendaDays(
return byDay.map { (date, dayEvents) ->
AgendaDay(
date = date,
events = dayEvents.sortedForDisplay(zone),
events = dayEvents.sortedWith(eventDisplayComparator(zone)),
)
}
}
@@ -755,21 +755,12 @@ class TimelineDragController {
/** Keep [landed]'s copy on the target as [settling] until [release]; [end] is a resize's. */
private fun settle(landed: TimelineDrag, end: Instant? = null) {
// Landed on midnight to midnight, the event moves to the all-day strip
// (#296): no block will seat its copy or carry the selection.
val leavesGrid = landed.eventSpanMin > 0 &&
landed.eventStartMin.mod(MINUTES_PER_DAY) == 0 &&
landed.eventSpanMin % MINUTES_PER_DAY == 0
if (leavesGrid && !landed.event.isDraft && selection?.event?.instanceId == landed.event.instanceId) {
selection = null
selectionCovered = false
}
settlingSelected = selection?.event?.instanceId == landed.event.instanceId
// An edge piece is a hint for the finger, not a preview of the landing
// slot: the event belongs to a day this timeline has no column for, so
// [noteGrid] can never seat it and it would sit at the top of today for
// the whole settle timeout, long after the event moved off-screen.
settling = if (landed.edgeOnly || leavesGrid) landed.copy(pieces = emptyList()) else landed
settling = if (landed.edgeOnly) landed.copy(pieces = emptyList()) else landed
liftedInstanceId = landed.event.instanceId
settledOrigin = landed.event.eventId to landed.event.start
settledAbandonedMark = abandonedTicks?.value ?: 0
@@ -4,7 +4,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.Dp
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.coversWholeDays
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
import de.jeanlucmakiola.calendula.ui.common.rememberInlineTimeWidth
import kotlinx.datetime.TimeZone
@@ -12,8 +11,8 @@ import java.util.Locale
/**
* The start time a chip shows before its title (#219), or null when it has none
* to show: a chip filling whole days never carries a time (#296), and neither
* does a bar carried in from an earlier week, whose start is not in this segment.
* to show: an all-day chip never carries a time, and neither does a bar carried
* in from an earlier week, whose start is not in this segment.
*/
internal fun monthChipTime(
event: EventInstance,
@@ -22,7 +21,7 @@ internal fun monthChipTime(
is24Hour: Boolean,
locale: Locale,
): String? {
if (event.coversWholeDays(zone) || continuesLeft) return null
if (event.isAllDay || continuesLeft) return null
return formatTimeOfDay(event.start, zone, is24Hour, locale)
}
@@ -13,7 +13,6 @@ import de.jeanlucmakiola.calendula.data.time.currentDate
import de.jeanlucmakiola.calendula.data.time.stateFollowingDays
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.coversWholeDays
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.calendarListFailure
import de.jeanlucmakiola.calendula.ui.agenda.AgendaDay
@@ -21,7 +20,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.anchorTodayIfMissing
import de.jeanlucmakiola.calendula.ui.common.CalendarSpan
import de.jeanlucmakiola.calendula.ui.common.PageStateCache
import de.jeanlucmakiola.calendula.domain.isCrossedOut
import de.jeanlucmakiola.calendula.domain.sortedForDisplay
import de.jeanlucmakiola.calendula.domain.eventDisplayComparator
import de.jeanlucmakiola.calendula.ui.week.coversDay
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
import de.jeanlucmakiola.calendula.ui.week.spansMultipleDays
@@ -492,7 +491,7 @@ internal fun layoutCalendarWeek(
// crossing midnight into the next week row covers a single day in each, and
// counting per row drew it as two unrelated pills (#253).
val (bars, singles) = weekEvents.partition { ev ->
ev.coversWholeDays(zone) || ev.spansMultipleDays(zone)
ev.isAllDay || ev.spansMultipleDays(zone)
}
val spans = layoutAllDay(bars, days, zone).map { s ->
MonthSpan(
@@ -532,7 +531,7 @@ internal fun instancesByDay(
days.associateWith { day ->
instances
.filter { it.coversDay(day, zone) }
.sortedForDisplay(zone)
.sortedWith(eventDisplayComparator(zone))
}
/**
@@ -13,7 +13,6 @@ import de.jeanlucmakiola.calendula.data.time.currentDate
import de.jeanlucmakiola.calendula.data.time.stateFollowingDays
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.coversWholeDays
import de.jeanlucmakiola.calendula.domain.displayPriority
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.calendarListFailure
@@ -207,7 +206,8 @@ abstract class PagedTimelineViewModel(
): WeekUiState {
calendarListFailure(calendars)?.let { return WeekUiState.Failure(it) }
val days = (0 until span.count).map { span.start.plus(it, DateTimeUnit.DAY) }
val (allDay, timed) = instances.partition { it.coversWholeDays(zone) }
val allDay = instances.filter { it.isAllDay }
val timed = instances.filterNot { it.isAllDay }
return WeekUiState.Success(
weekStart = span.start,
today = today,
@@ -292,17 +292,17 @@ internal fun layoutAllDay(
days: List<LocalDate>,
zone: TimeZone,
): List<AllDaySpan> {
data class Raw(val event: EventInstance, val startCol: Int, val endCol: Int, val priority: Int)
data class Raw(val event: EventInstance, val startCol: Int, val endCol: Int)
val raw = events
.mapNotNull { ev ->
val covered = days.indices.filter { ev.coversDay(days[it], zone) }
if (covered.isEmpty()) null else Raw(ev, covered.first(), covered.last(), ev.displayPriority(zone))
if (covered.isEmpty()) null else Raw(ev, covered.first(), covered.last())
}
// Declined bars are packed after every other one, so on any day they
// cover they land in a lane below it (#230).
.sortedWith(
compareBy<Raw> { it.priority }
compareBy<Raw> { it.event.displayPriority(zone) }
.thenBy { it.startCol }
.thenBy { it.endCol }
.thenBy { it.event.start }
@@ -444,7 +444,7 @@ internal fun layoutDay(
data class Raw(val event: EventInstance, val startMin: Int, val endMin: Int)
val raw = events.asSequence()
.filterNot { it.coversWholeDays(zone) }
.filterNot { it.isAllDay }
.mapNotNull { ev ->
if (ev.start == ev.end) {
// Instant event: keep only if the point falls inside this day.
@@ -1,7 +1,6 @@
package de.jeanlucmakiola.calendula.ui.week
import de.jeanlucmakiola.calendula.ui.common.startOfWeek
import de.jeanlucmakiola.calendula.domain.coversWholeDays
import androidx.compose.foundation.rememberScrollState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -81,7 +80,7 @@ private fun sampleWeekState(
weekStart = start,
today = today,
days = days,
allDaySpans = layoutAllDay(events.filter { it.coversWholeDays(zone) }, days, zone),
allDaySpans = layoutAllDay(events.filter { it.isAllDay }, days, zone),
timedByDay = days.associateWith { layoutDay(events, it, zone) },
)
}
@@ -2,7 +2,6 @@ package de.jeanlucmakiola.calendula.widget.countdown
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.coversWholeDays
import de.jeanlucmakiola.calendula.domain.isCrossedOut
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.TimeZone
@@ -76,13 +75,12 @@ fun nextLabelChange(event: EventInstance, now: Instant, zone: TimeZone): Instant
}
/**
* The next-event mode's pick: timed and not filling whole days (#296), not
* declined or cancelled, not over; of those, the one whose countdown changes
* soonest — a running event's end, else a start.
* The next-event mode's pick: timed, not declined or cancelled, not over; of those, the one
* whose countdown changes soonest — a running event's end, else a start.
*/
fun pickNextEvent(instances: List<EventInstance>, now: Instant, zone: TimeZone): EventInstance? =
fun pickNextEvent(instances: List<EventInstance>, now: Instant): EventInstance? =
instances
.filter { !it.coversWholeDays(zone) && !it.isCrossedOut && it.end > now }
.filter { !it.isAllDay && !it.isCrossedOut && it.end > now }
.minByOrNull { target(it, now) }
/** A pinned event's occurrence to count to: the running or next one that is not cancelled (#388). */
@@ -75,11 +75,7 @@ internal suspend fun Context.loadCountdown(
val repository = widgetEntryPoint().calendarRepository()
val event = when (options.mode) {
CountdownMode.NEXT ->
pickNextEvent(
repository.instances(now..now + NextEventHorizon).first().withoutCalendars(load.hiddenCalendars),
now,
systemZone(),
)
pickNextEvent(repository.instances(now..now + NextEventHorizon).first().withoutCalendars(load.hiddenCalendars), now)
CountdownMode.PINNED -> options.eventId?.let { id ->
pickPinnedOccurrence(repository.eventOccurrences(id, now - 1.days..now + PinnedHorizon), now, systemZone())
}
@@ -40,7 +40,6 @@ import androidx.glance.text.TextStyle
import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.coversWholeDays
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
@@ -211,13 +210,12 @@ private fun whenText(context: Context, event: EventInstance, data: CountdownData
val at = if (running) counted.end else counted.start
val date = at.toLocalDateTime(systemZone()).date
val day = relativeDayLabel(context, date, data.today)
val wholeDays = event.coversWholeDays(systemZone())
return when {
wholeDays && running -> context.getString(
event.isAllDay && running -> context.getString(
R.string.widget_countdown_until,
relativeDayLabel(context, date.minus(1, DateTimeUnit.DAY), data.today),
)
wholeDays -> "$day · ${context.getString(R.string.event_detail_all_day)}"
event.isAllDay -> "$day · ${context.getString(R.string.event_detail_all_day)}"
running -> context.getString(R.string.agenda_span_ends, "$day · ${formatWidgetTime(at, data.is24Hour)}")
else -> "$day · ${formatWidgetTime(at, data.is24Hour)}"
}
@@ -212,30 +212,4 @@ class ReminderTimeTextTest {
)
assertThat(text).isEqualTo("Today")
}
@Test
fun `a timed event from local midnight to midnight reads like an all-day one`() {
val oneDay = text(
beginMillis = millisAt(LocalDateTime.of(2026, 6, 12, 0, 0), berlin),
endMillis = millisAt(LocalDateTime.of(2026, 6, 13, 0, 0), berlin),
today = LocalDate.of(2026, 6, 11),
)
assertThat(oneDay).isEqualTo("Tomorrow")
val twoDays = text(
beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 0, 0), berlin),
endMillis = millisAt(LocalDateTime.of(2026, 6, 13, 0, 0), berlin),
)
assertThat(twoDays).startsWith("Today – ")
}
@Test
fun `midnight is read in the device zone`() {
// Midnight in Berlin is 22:00 the day before in UTC, so it is timed there.
val text = text(
beginMillis = millisAt(LocalDateTime.of(2026, 6, 12, 0, 0), berlin),
endMillis = millisAt(LocalDateTime.of(2026, 6, 13, 0, 0), berlin),
zone = ZoneOffset.UTC,
)
assertThat(text).contains("22:00")
}
}
@@ -82,39 +82,4 @@ class EventInstanceSpanTest {
val timed = instance(utc(2026, 7, 19, 12), utc(2026, 7, 19, 13), isAllDay = false)
assertThat(timed.dateZone(newYork)).isEqualTo(newYork)
}
@Test
fun `a timed event from local midnight to local midnight covers whole days`() {
// Stored as 22:00Z, the shape ICSx5 gives a date-only feed event (#296).
val oneDay = instance(utc(2026, 7, 18, 22), utc(2026, 7, 19, 22), isAllDay = false)
val twoDays = instance(utc(2026, 7, 18, 22), utc(2026, 7, 20, 22), isAllDay = false)
assertThat(oneDay.coversWholeDays(berlin)).isTrue()
assertThat(twoDays.coversWholeDays(berlin)).isTrue()
}
@Test
fun `local midnight is read in the device zone`() {
val event = instance(utc(2026, 7, 18, 22), utc(2026, 7, 19, 22), isAllDay = false)
assertThat(event.coversWholeDays(newYork)).isFalse()
}
@Test
fun `a timed event off midnight at either end does not cover whole days`() {
assertThat(instance(utc(2026, 7, 19), utc(2026, 7, 19, 23), isAllDay = false).coversWholeDays(TimeZone.UTC))
.isFalse()
assertThat(instance(utc(2026, 7, 19, 1), utc(2026, 7, 20), isAllDay = false).coversWholeDays(TimeZone.UTC))
.isFalse()
}
@Test
fun `a zero-length event at midnight does not cover whole days`() {
assertThat(instance(utc(2026, 7, 19), utc(2026, 7, 19), isAllDay = false).coversWholeDays(TimeZone.UTC))
.isFalse()
}
@Test
fun `an all-day event covers whole days in any zone`() {
assertThat(allDayJul19().coversWholeDays(newYork)).isTrue()
assertThat(allDayJul19().coversWholeDays(berlin)).isTrue()
}
}
@@ -65,14 +65,4 @@ class AgendaTimeLabelTest {
assertThat(labelOn(2026, 7, 1, e)).isEqualTo(AgendaTimeLabel.AllDay)
assertThat(labelOn(2026, 7, 2, e)).isEqualTo(AgendaTimeLabel.Ends(e.end))
}
@Test
fun `a timed event from midnight to midnight is all-day`() {
val oneDay = event(at(2026, 7, 2), at(2026, 7, 3))
val twoDays = event(at(2026, 7, 2), at(2026, 7, 4))
assertThat(labelOn(2026, 7, 2, oneDay)).isEqualTo(AgendaTimeLabel.AllDay)
assertThat(labelOn(2026, 7, 2, twoDays)).isEqualTo(AgendaTimeLabel.AllDay)
assertThat(labelOn(2026, 7, 3, twoDays)).isEqualTo(AgendaTimeLabel.AllDay)
}
}
@@ -321,18 +321,4 @@ class MonthLayoutTest {
private fun declinedTimed(date: LocalDate) =
timed(date, 7, 8, id = 4L, title = "Declined early")
.copy(response = EventResponse.Declined)
@Test
fun `a timed event from midnight to midnight is a bar, not a timed chip`() {
val ev = timedEvent(
LocalDate(2026, 6, 10), 0, 0, zone = zone, id = 3L,
endDate = LocalDate(2026, 6, 11),
)
val week = layoutCalendarWeek(weekOf8th, listOf(ev), zone)
assertThat(week.spans.map { it.event }).containsExactly(ev)
assertThat(week.spans.single().startCol).isEqualTo(2)
assertThat(week.spans.single().endCol).isEqualTo(2)
assertThat(week.timedByDay.values.flatten()).isEmpty()
}
}
@@ -300,11 +300,4 @@ class WeekLayoutTest {
val second = event(Instant.parse("2026-10-25T01:10:00Z"), Instant.parse("2026-10-25T01:40:00Z"))
assertThat(layoutDay(listOf(second), day, brussels).single().endMin).isEqualTo(2 * 60 + 40)
}
@Test
fun `a timed event from midnight to midnight stays out of the time grid`() {
val ev = testEvent(start = at(wed, 0), end = at(wed.plus(1, DateTimeUnit.DAY), 0))
assertThat(layoutDay(listOf(ev), wed, zone)).isEmpty()
}
}
@@ -71,16 +71,9 @@ class CountdownTest {
val over = event(now - 2.hours, now - 1.hours, id = 3)
val later = event(now + 3.hours, id = 4)
val running = event(now - 1.hours, now + 2.hours, id = 5)
assertThat(pickNextEvent(listOf(allDay, declined, over, later), now, zone)?.eventId).isEqualTo(4)
assertThat(pickNextEvent(listOf(allDay, declined, over, later), now)?.eventId).isEqualTo(4)
// A running event ending before the next one starts comes first.
assertThat(pickNextEvent(listOf(later, running), now, zone)?.eventId).isEqualTo(5)
}
@Test
fun `next-event mode skips a timed event filling whole days`() {
val wholeDay = event(Instant.parse("2026-10-22T00:00:00Z"), Instant.parse("2026-10-23T00:00:00Z"), id = 1)
val later = event(now + 30.hours, id = 2)
assertThat(pickNextEvent(listOf(wholeDay, later), now, zone)?.eventId).isEqualTo(2)
assertThat(pickNextEvent(listOf(later, running), now)?.eventId).isEqualTo(5)
}
@Test