From 8c305ffa551f1c3b5e39360046ad31f247dfa18a Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 14 Oct 2026 14:02:12 +0200 Subject: [PATCH] Fix DST edge cases in resize, drafts and the event form (#387) - A resized edge keeps its own pass of the repeated hour, else the other edge's, so an end pulled into the hour an event started in its second pass no longer lands before the start. - A drafted block opens the form at its real length rather than the end it is drawn to, so a draft ending in the repeated hour keeps its length and one starting in the spring-forward gap no longer ends before it starts. - A form only keeps an offset for a time in the repeated hour, so a day move doesn't depend on the season of the occurrence it came from and a time changed and changed back compares equal again. - Changing the form's zone, or going all-day, drops a kept pass. - A drag works out the event's pass once instead of every frame. - The form caches the pinned-zone check and the time labels again. - Tests cover the pinned event's local times again. --- .../calendula/domain/EventForm.kt | 12 +-- .../calendula/domain/EventShift.kt | 21 ++++- .../calendula/domain/InsertEventForm.kt | 8 +- .../calendula/domain/ics/ParsedIcsForm.kt | 6 +- .../calendula/ui/CalendarHost.kt | 18 ++-- .../calendula/ui/common/TimelineDrag.kt | 89 ++++++++++-------- .../calendula/ui/edit/EventEditScreen.kt | 35 ++++--- .../calendula/ui/edit/EventEditViewModel.kt | 43 ++++----- .../calendula/ui/week/WeekScreen.kt | 8 +- .../calendula/domain/EventFormTest.kt | 91 +++++++++++++++++++ .../calendula/domain/EventShiftTest.kt | 18 ++++ .../calendula/ui/common/TimelineDropTest.kt | 33 ++++++- 12 files changed, 275 insertions(+), 107 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt index ac2bec3..2a4b75a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt @@ -5,7 +5,6 @@ import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalTime import kotlinx.datetime.TimeZone import kotlinx.datetime.UtcOffset -import kotlinx.datetime.offsetAt import kotlinx.datetime.toLocalDateTime import kotlin.time.Instant @@ -38,9 +37,10 @@ data class EventForm( */ val timezone: String? = null, /** - * The UTC offsets [start]/[end] were read at, when they came from real - * instants. Only a wall-clock time the fall-back hour repeats needs them: - * both of its instants share one [LocalDateTime] (#387). + * The UTC offsets [start]/[end] were read at, set only when the fall-back + * hour repeats that wall clock: both of its instants share one + * [LocalDateTime] (#387). Null everywhere else, so a time changed and changed + * back compares equal again. See [repeatedHourOffsetAt]. */ val startOffset: UtcOffset? = null, val endOffset: UtcOffset? = null, @@ -170,8 +170,8 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone): start = start, end = end, timezone = pinnedZone, - startOffset = if (instance.isAllDay) null else formZone.offsetAt(begin), - endOffset = if (instance.isAllDay) null else formZone.offsetAt(finish), + startOffset = if (instance.isAllDay) null else formZone.repeatedHourOffsetAt(begin), + endOffset = if (instance.isAllDay) null else formZone.repeatedHourOffsetAt(finish), location = instance.location.orEmpty(), description = description.orEmpty(), reminders = reminders.map { it.minutes }.distinct().sorted(), diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventShift.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventShift.kt index bb0db96..919fa4e 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventShift.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventShift.kt @@ -34,6 +34,13 @@ fun Instant.inRepeatedHour(zone: TimeZone): Boolean { return zid.rules.getValidOffsets(java.time.LocalDateTime.ofInstant(toJavaInstant(), zid)).size > 1 } +/** + * [at]'s offset in this zone when its wall clock is one the fall-back hour + * repeats, else null: elsewhere the wall clock alone already names the instant. + * What [EventForm.startOffset]/[EventForm.endOffset] hold (#387). + */ +fun TimeZone.repeatedHourOffsetAt(at: Instant): UtcOffset? = if (at.inRepeatedHour(this)) offsetAt(at) else null + /** The form's start as an instant in [zone], keeping its [EventForm.startOffset]. */ fun EventForm.startInstant(zone: TimeZone): Instant = start.resolveIn(zone, startOffset) @@ -44,10 +51,20 @@ fun EventForm.endInstant(zone: TimeZone): Instant = end.resolveIn(zone, endOffse fun EventForm.withTimes(start: Instant, end: Instant, zone: TimeZone): EventForm = copy( start = start.toLocalDateTime(zone), end = end.toLocalDateTime(zone), - startOffset = zone.offsetAt(start), - endOffset = zone.offsetAt(end), + startOffset = zone.repeatedHourOffsetAt(start), + endOffset = zone.repeatedHourOffsetAt(end), ) +/** + * The form pinned to [zoneId] (null: the device). A kept pass of a repeated hour + * was one of the old zone's, so it goes when the zone the times mean changes. + */ +fun EventForm.withTimezone(zoneId: String?, deviceZone: TimeZone): EventForm { + val pinned = copy(timezone = zoneId) + if (pinned.resolvedZone(deviceZone) == resolvedZone(deviceZone)) return pinned + return pinned.copy(startOffset = null, endOffset = null) +} + /** * The form with its end picked by hand. A picked wall-clock time can't say which * pass of a repeated hour it means, so it drops [EventForm.endOffset] and takes diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/InsertEventForm.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/InsertEventForm.kt index e6a40c8..8b407f8 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/InsertEventForm.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/InsertEventForm.kt @@ -4,7 +4,6 @@ import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalTime import kotlinx.datetime.TimeZone -import kotlinx.datetime.offsetAt import kotlinx.datetime.toLocalDateTime import kotlin.time.Duration.Companion.hours import kotlin.time.Instant @@ -64,8 +63,8 @@ fun buildInsertEventForm( isAllDay = isAllDay, start = start, end = end, - startOffset = timed?.let { zone.offsetAt(it.first) }, - endOffset = timed?.let { zone.offsetAt(it.second) }, + startOffset = timed?.let { zone.repeatedHourOffsetAt(it.first) }, + endOffset = timed?.let { zone.repeatedHourOffsetAt(it.second) }, location = location.orEmpty(), description = description.orEmpty(), // Bare RRULE value (Events.RRULE convention); tolerate a leading "RRULE:" @@ -74,7 +73,8 @@ fun buildInsertEventForm( ) } -private fun nextFullHour(now: Instant): Instant { +/** The first full hour after [now]: may roll into the next day before midnight. */ +internal fun nextFullHour(now: Instant): Instant { val hourMillis = 3_600_000L return Instant.fromEpochMilliseconds((now.toEpochMilliseconds() / hourMillis + 1) * hourMillis) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/ParsedIcsForm.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/ParsedIcsForm.kt index 4263c78..5560365 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/ParsedIcsForm.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/ParsedIcsForm.kt @@ -1,11 +1,11 @@ package de.jeanlucmakiola.calendula.domain.ics import de.jeanlucmakiola.calendula.domain.EventForm +import de.jeanlucmakiola.calendula.domain.repeatedHourOffsetAt import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalTime import kotlinx.datetime.TimeZone -import kotlinx.datetime.offsetAt import kotlinx.datetime.toLocalDateTime /** @@ -35,8 +35,8 @@ fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm { isAllDay = isAllDay, start = start, end = end, - startOffset = if (isAllDay) null else zone.offsetAt(this.start), - endOffset = if (isAllDay) null else zone.offsetAt(this.end), + startOffset = if (isAllDay) null else zone.repeatedHourOffsetAt(this.start), + endOffset = if (isAllDay) null else zone.repeatedHourOffsetAt(this.end), location = location.orEmpty(), description = description.orEmpty(), reminders = semanticReminderMinutes(), diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt index 704481f..2b62c13 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -239,16 +239,16 @@ fun CalendarHost( var heldCreateIso by remember { mutableStateOf(null) } var createStartMinutes by rememberSaveable { mutableStateOf(null) } var heldCreateMinutes by remember { mutableStateOf(null) } - // A drafted block's end (#153), in minutes from the start day's midnight. - var createEndMinutes by rememberSaveable { mutableStateOf(null) } - var heldCreateEndMinutes by remember { mutableStateOf(null) } - val onCreateSpan: (LocalDate, Int?, Int?) -> Unit = { date, startMinutes, endMinutes -> + // A drafted block's length (#153), in minutes. + var createDurationMinutes by rememberSaveable { mutableStateOf(null) } + var heldCreateDurationMinutes by remember { mutableStateOf(null) } + val onCreateSpan: (LocalDate, Int?, Int?) -> Unit = { date, startMinutes, durationMinutes -> heldCreateIso = date.toString() createDateIso = date.toString() heldCreateMinutes = startMinutes createStartMinutes = startMinutes - heldCreateEndMinutes = endMinutes - createEndMinutes = endMinutes + heldCreateDurationMinutes = durationMinutes + createDurationMinutes = durationMinutes } val onCreateEvent: (LocalDate, Int?) -> Unit = { date, startMinutes -> onCreateSpan(date, startMinutes, null) } @@ -386,8 +386,8 @@ fun CalendarHost( createDateIso = iso heldCreateMinutes = null createStartMinutes = null - heldCreateEndMinutes = null - createEndMinutes = null + heldCreateDurationMinutes = null + createDurationMinutes = null onWidgetNavConsumed() } null -> {} @@ -680,7 +680,7 @@ fun CalendarHost( EventEditScreen( initialDateIso = iso, initialStartMinutes = createStartMinutes ?: heldCreateMinutes, - initialEndMinutes = createEndMinutes ?: heldCreateEndMinutes, + initialDurationMinutes = createDurationMinutes ?: heldCreateDurationMinutes, onClose = { createDateIso = null }, onSaved = { createDateIso = null }, onManageCalendars = { showCalendars = true }, 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 bebd762..9afa7eb 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 @@ -46,7 +46,7 @@ import androidx.compose.ui.unit.dp import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.domain.EventInstance -import de.jeanlucmakiola.calendula.domain.inRepeatedHour +import de.jeanlucmakiola.calendula.domain.repeatedHourOffsetAt import de.jeanlucmakiola.calendula.domain.resolveIn import de.jeanlucmakiola.calendula.ui.week.MINUTES_PER_DAY import de.jeanlucmakiola.calendula.ui.week.TimedBlock @@ -60,7 +60,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.LocalDate import kotlinx.datetime.atStartOfDayIn -import kotlinx.datetime.offsetAt +import kotlinx.datetime.UtcOffset import kotlinx.datetime.plus import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalTime @@ -319,6 +319,39 @@ enum class ResizeEdge { Top, Bottom } /** Where a finished resize asks its event to begin and end. */ data class TimelineResizeDrop(val event: EventInstance, val start: Instant, val end: Instant) +/** + * [event] resized by its [edge] to the span [startMin]..+[spanMin] from [date]'s + * midnight. Only the held edge moves. The other keeps its own instant rather than + * a round trip through the grid, where an end in the fall-back hour's repeat is + * drawn to that hour's end, not at its own time (#387). The held edge keeps its + * own pass of a repeated hour, else the other edge's: an end pulled into the + * later pass an event starts in stays after that start. + */ +internal fun resizedDrop( + event: EventInstance, + edge: ResizeEdge?, + date: LocalDate, + startMin: Int, + spanMin: Int, + zone: TimeZone, +): TimelineResizeDrop { + val startKept = zone.repeatedHourOffsetAt(event.start) + val endKept = zone.repeatedHourOffsetAt(event.end) + return TimelineResizeDrop( + event = event, + start = if (edge == ResizeEdge.Top) { + wallClockInstant(date, startMin, zone, kept = startKept ?: endKept) + } else { + event.start + }, + end = if (edge == ResizeEdge.Bottom) { + wallClockInstant(date, startMin + spanMin, zone, kept = endKept ?: startKept) + } else { + event.end + }, + ) +} + /** * The span after dragging [edge] to [edgeMin] in day [edgeDay], in minutes from the * start day's midnight. Snapped, and at least one snap step long. @@ -556,6 +589,8 @@ class TimelineDragController { private var clipOffsetMin = 0 private var dragZone: TimeZone? = null private var dragDuration = Duration.ZERO + /** The dragged event's pass of a repeated hour, worked out once per drag (#387). */ + private var dragKept: UtcOffset? = null /** What the picked-up block drew its title over — see [TimelineDrag.titleLines]. */ private var titleLines = 0 @@ -600,6 +635,7 @@ class TimelineDragController { this.clipOffsetMin = clipOffsetMin dragZone = zone dragDuration = (block.event.end - block.event.start).coerceAtLeast(Duration.ZERO) + dragKept = zone.repeatedHourOffsetAt(block.event.start) this.titleLines = titleLines settling = null isDragging = true @@ -638,6 +674,7 @@ class TimelineDragController { clipOffsetMin = 0 dragZone = null dragDuration = Duration.ZERO + dragKept = null titleLines = 0 isDragging = false pointerMoved = false @@ -765,27 +802,7 @@ class TimelineDragController { val edge = resizeEdge cancel() if (landed == null || landed.slot == origin) return null - // Only the held edge moves. The other keeps its own instant rather than - // a round trip through the grid, where an end in the fall-back hour's - // repeat is drawn to that hour's end, not at its own time (#387). - val drop = TimelineResizeDrop( - event = landed.event, - start = if (edge == ResizeEdge.Top) { - wallClockInstant(landed.date, landed.eventStartMin, zone, keepOffsetOf = landed.event.start) - } else { - landed.event.start - }, - end = if (edge == ResizeEdge.Bottom) { - wallClockInstant( - landed.date, - landed.eventStartMin + landed.eventSpanMin, - zone, - keepOffsetOf = landed.event.end, - ) - } else { - landed.event.end - }, - ) + val drop = resizedDrop(landed.event, edge, landed.date, landed.eventStartMin, landed.eventSpanMin, zone) settlingSelected = selection?.event?.instanceId == landed.event.instanceId settling = if (landed.edgeOnly) landed.copy(pieces = emptyList()) else landed liftedInstanceId = landed.event.instanceId @@ -917,7 +934,7 @@ class TimelineDragController { val column = ((pointer.x - origin.x) / columnPx).toInt().coerceIn(0, days.lastIndex) val dayIndex = if (geometry.isRtl) days.lastIndex - column else column val target = days[dayIndex] - val keep = block.event.start + val keep = dragKept // Measured where it would land: the drop keeps the real duration, which in // wall clock gains or loses the hour of a DST change it crosses (#387). val floor = dragFloorMin( @@ -1038,7 +1055,7 @@ fun TimedBlock.continuesAfter(day: LocalDate, zone: TimeZone): Boolean = * visibly settles there. */ fun TimelineDrop.startInstant(zone: TimeZone): Instant = - wallClockInstant(date, startMin - clipOffsetMin, zone, keepOffsetOf = event.start) + wallClockInstant(date, startMin - clipOffsetMin, zone, kept = zone.repeatedHourOffsetAt(event.start)) /** * The wall-clock minutes an event lasting [duration] covers when it starts @@ -1050,28 +1067,22 @@ internal fun wallClockSpanMinutes( startMin: Int, duration: Duration, zone: TimeZone, - keepOffsetOf: Instant? = null, + kept: UtcOffset? = null, ): Int { - val start = wallClockInstant(date, startMin, zone, keepOffsetOf) + val start = wallClockInstant(date, startMin, zone, kept) return wallClockEndMinutes(startMin, start, start + duration, date, zone) - startMin } /** * [minutes] past [date]'s midnight, either way past it, as wall clock in [zone] * (DST-safe). A time the fall-back hour repeats lands on its earlier pass, unless - * [keepOffsetOf] is itself in that hour's later one: an event there stays on it - * (#387). An instant outside the hour says nothing about which pass the grid - * slot means, so a drop never depends on the season the event came from. + * [kept] is its later one: an event there stays on it (#387). Pass + * [repeatedHourOffsetAt] of the event's time, which is null outside the hour — + * such an instant says nothing about which pass the grid slot means, so a drop + * never depends on the season the event came from. */ -fun wallClockInstant( - date: LocalDate, - minutes: Int, - zone: TimeZone, - keepOffsetOf: Instant? = null, -): Instant { - val kept = keepOffsetOf?.takeIf { it.inRepeatedHour(zone) }?.let(zone::offsetAt) - return wallClockTime(date, minutes).resolveIn(zone, kept) -} +fun wallClockInstant(date: LocalDate, minutes: Int, zone: TimeZone, kept: UtcOffset? = null): Instant = + wallClockTime(date, minutes).resolveIn(zone, kept) /** The wall-clock time [minutes] past [date]'s midnight, on a later day past 1440. */ fun wallClockTime(date: LocalDate, minutes: Int): LocalDateTime { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index 4d1a14b..0298958 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt @@ -205,7 +205,7 @@ fun EventEditScreen( onSaved: () -> Unit, editKey: LongArray? = null, initialStartMinutes: Int? = null, - initialEndMinutes: Int? = null, + initialDurationMinutes: Int? = null, initialForm: EventForm? = null, initialFormSource: ImportSource = ImportSource.File, onManageCalendars: (() -> Unit)? = null, @@ -225,7 +225,7 @@ fun EventEditScreen( else -> { val date = initialDateIso?.let { runCatching { LocalDate.parse(it) }.getOrNull() } ?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date - viewModel.openNew(date, initialStartMinutes, initialEndMinutes) + viewModel.openNew(date, initialStartMinutes, initialDurationMinutes) } } } @@ -706,7 +706,13 @@ private fun EventEditContent( // whoever is reading it. Spell the local equivalent out rather than // leaving the user to do the offset arithmetic. Absent unless the // event is pinned somewhere other than here. - if (form.isPinnedAwayFrom(TimeZone.currentSystemDefault())) { + // Keyed rather than recomputed: this sits in the same Column as the + // title field, so it would otherwise re-parse the zone on every + // keystroke. + val pinnedAway = remember(form.timezone, form.isAllDay) { + form.isPinnedAwayFrom(TimeZone.currentSystemDefault()) + } + if (pinnedAway) { Spacer(Modifier.height(2.dp)) Text( text = stringResource( @@ -2264,14 +2270,16 @@ private fun EditCard( @Composable private fun formatTimeRange(start: Instant, end: Instant, zone: TimeZone, locale: Locale): String { val use24Hour = LocalUse24HourFormat.current - val timeFormat = remember(locale, use24Hour) { timeOfDayFormatter(use24Hour, locale) } - fun format(at: Instant) = withRepeatedHourZone( - timeFormat.format(at.toLocalDateTime(zone).time.toJavaLocalTime()), - at, - zone, - locale, - ) - return if (start == end) format(start) else "${format(start)} – ${format(end)}" + return remember(start, end, zone, locale, use24Hour) { + val timeFormat = timeOfDayFormatter(use24Hour, locale) + fun format(at: Instant) = withRepeatedHourZone( + timeFormat.format(at.toLocalDateTime(zone).time.toJavaLocalTime()), + at, + zone, + locale, + ) + if (start == end) format(start) else "${format(start)} – ${format(end)}" + } } /** @@ -2396,8 +2404,11 @@ private fun ScheduleRow( .padding(vertical = 8.dp, horizontal = 6.dp), ) if (!isAllDay) { + val time = remember(dateTime, instant, zone, locale, timeFormat) { + withRepeatedHourZone(timeFormat.format(dateTime.time.toJavaLocalTime()), instant, zone, locale) + } Text( - text = withRepeatedHourZone(timeFormat.format(dateTime.time.toJavaLocalTime()), instant, zone, locale), + text = time, style = MaterialTheme.typography.titleMedium, color = valueColor, modifier = Modifier diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt index e124d0a..be843e3 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt @@ -20,11 +20,13 @@ import de.jeanlucmakiola.calendula.domain.EventAttendee import de.jeanlucmakiola.calendula.domain.EventColorOption import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.endInstant +import de.jeanlucmakiola.calendula.domain.nextFullHour import de.jeanlucmakiola.calendula.domain.resolveIn import de.jeanlucmakiola.calendula.domain.resolvedZone import de.jeanlucmakiola.calendula.domain.startInstant import de.jeanlucmakiola.calendula.domain.withEnd import de.jeanlucmakiola.calendula.domain.withTimes +import de.jeanlucmakiola.calendula.domain.withTimezone import de.jeanlucmakiola.calendula.domain.EventFormField import de.jeanlucmakiola.calendula.domain.RecurringWriteScope import de.jeanlucmakiola.calendula.domain.isEventTarget @@ -32,7 +34,6 @@ import de.jeanlucmakiola.calendula.domain.populatedFields import de.jeanlucmakiola.calendula.domain.problems import de.jeanlucmakiola.calendula.domain.toEditSnapshot import de.jeanlucmakiola.calendula.ui.detail.EventDetailViewModel.Companion.NO_OCCURRENCE_TIME -import de.jeanlucmakiola.calendula.ui.common.wallClockTime import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow @@ -56,13 +57,11 @@ import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalTime import kotlinx.datetime.TimeZone -import kotlinx.datetime.offsetAt import kotlinx.datetime.toInstant import kotlinx.datetime.toLocalDateTime import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Clock import kotlin.time.Duration.Companion.minutes -import kotlin.time.Instant import javax.inject.Inject private const val TAG = "EventEdit" @@ -336,36 +335,28 @@ class EventEditViewModel @Inject constructor( * in the day/week grid; without it the default is the next full hour (today) * or 09:00 (any other day). The event opens an hour long and is stretched to * the configured default by [applyDefaultDuration], like the reminders. - * A block drafted on the timeline (#153) passes [endMinutes] instead, - * counted from [date]'s midnight and past 1440 for a later day. + * A block drafted on the timeline (#153) passes its length as + * [durationMinutes] instead: a real length, which a wall-clock end can't + * give across a DST change or a start pushed out of the spring-forward gap. + * A start in the fall-back hour's repeat takes the earlier pass. * No-op when a form is already open, so user input survives configuration * changes; [reset] clears it when the screen closes. */ - fun openNew(date: LocalDate, startMinutes: Int? = null, endMinutes: Int? = null) { + fun openNew(date: LocalDate, startMinutes: Int? = null, durationMinutes: Int? = null) { if (_form.value != null) return val zone = TimeZone.currentSystemDefault() val now = Clock.System.now() val startAt = when { startMinutes != null -> LocalDateTime(date, LocalTime(startMinutes / 60, startMinutes % 60)).toInstant(zone) - date == now.toLocalDateTime(zone).date -> { - // Today: the next full hour (may roll into tomorrow before midnight). - val hourMillis = 3_600_000L - Instant.fromEpochMilliseconds((now.toEpochMilliseconds() / hourMillis + 1) * hourMillis) - } + date == now.toLocalDateTime(zone).date -> nextFullHour(now) else -> LocalDateTime(date, LocalTime(9, 0)).toInstant(zone) } - val chosenEnd = endMinutes?.takeIf { startMinutes != null && it > startMinutes } - ?.let { wallClockTime(date, it).resolveIn(zone, zone.offsetAt(startAt)) } - val endAt = chosenEnd ?: (startAt + SettingsPrefs.DEFAULT_EVENT_DURATION.minutes) - _form.value = EventForm( - calendarId = null, - start = startAt.toLocalDateTime(zone), - end = endAt.toLocalDateTime(zone), - startOffset = zone.offsetAt(startAt), - endOffset = zone.offsetAt(endAt), - ) - if (chosenEnd != null) _durationTouched.value = true + val chosen = durationMinutes?.takeIf { startMinutes != null && it > 0 } + val endAt = startAt + (chosen ?: SettingsPrefs.DEFAULT_EVENT_DURATION).minutes + val start = startAt.toLocalDateTime(zone) + _form.value = EventForm(calendarId = null, start = start, end = start).withTimes(startAt, endAt, zone) + if (chosen != null) _durationTouched.value = true applyDefaultReminder() applyDefaultDuration() } @@ -590,7 +581,11 @@ class EventEditViewModel @Inject constructor( // the provider stores UTC-anchored, so a zone could only misrepresent // them. Coming back out of all-day leaves the event on the device zone, // which is the same thing a fresh event gets. - update { it.copy(isAllDay = value, timezone = if (value) null else it.timezone) } + // An all-day form's times are bare dates, so a kept pass goes with it. + update { + if (value) it.copy(isAllDay = true, timezone = null, startOffset = null, endOffset = null) + else it.copy(isAllDay = false) + } // The default reminder differs for all-day vs timed; re-apply the // type-appropriate default unless the user has hand-edited it (guarded). applyDefaultReminder() @@ -605,7 +600,7 @@ class EventEditViewModel @Inject constructor( * time — only real picks are remembered, not the device fallback. */ fun setTimezone(zoneId: String?) { - update { it.copy(timezone = zoneId) } + update { it.withTimezone(zoneId, TimeZone.currentSystemDefault()) } if (zoneId != null) { viewModelScope.launch { withContext(io) { settingsPrefs.addRecentTimeZone(zoneId) } } } 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 134c4cf..11deb6c 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 @@ -74,7 +74,6 @@ import de.jeanlucmakiola.calendula.ui.common.rememberDragSurface import de.jeanlucmakiola.calendula.ui.common.rememberWriteGate import de.jeanlucmakiola.calendula.ui.common.OutOfViewEdges import de.jeanlucmakiola.calendula.ui.common.wallClockInstant -import de.jeanlucmakiola.calendula.ui.common.wallClockSpan import de.jeanlucmakiola.calendula.ui.common.PickUp import de.jeanlucmakiola.calendula.ui.common.SelectionBarClearance import de.jeanlucmakiola.calendula.ui.common.ResizeEdge @@ -598,11 +597,12 @@ private fun WeekContent( ) } - // A drafted block (#153) opens the new-event form at the span it shows. + // A drafted block (#153) opens the new-event form at its start, for its real + // length: the end it is drawn to differs on a DST change (#387). val createDraft: (EventInstance) -> Unit = { draft -> dragController.deselect() - val span = wallClockSpan(draft, zone) - onCreateSpan(span.day, span.startMin, span.endMin) + val start = draft.start.toLocalDateTime(zone) + onCreateSpan(start.date, start.time.toSecondOfDay() / 60, (draft.end - draft.start).inWholeMinutes.toInt()) } // Selection (#141) ends on back, a page or view change, or any tap. diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt index a99a8ff..a3f78f8 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt @@ -5,6 +5,8 @@ import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalTime import kotlinx.datetime.TimeZone +import kotlinx.datetime.UtcOffset +import kotlinx.datetime.toLocalDateTime import org.junit.jupiter.api.Test import kotlin.time.Instant @@ -423,4 +425,93 @@ class EventFormTest { assertThat(picked.endOffset).isNull() assertThat(picked.endInstant(berlin)).isEqualTo(Instant.parse("2026-10-25T00:50:00Z")) } + + @Test + fun `a pinned event's times land in the device zone`() { + val form = EventForm( + calendarId = 1L, + start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(8, 0)), + end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(9, 0)), + timezone = "America/New_York", + ) + val zone = form.resolvedZone(berlin) + // 08:00 New York (EDT, UTC-4) is 14:00 Berlin (CEST, UTC+2). + assertThat(form.startInstant(zone).toLocalDateTime(berlin)) + .isEqualTo(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(14, 0))) + assertThat(form.endInstant(zone).toLocalDateTime(berlin)) + .isEqualTo(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(15, 0))) + } + + @Test + fun `a pinned event's local time crosses the date line with the offset`() { + val form = EventForm( + calendarId = 1L, + start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(20, 0)), + end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(21, 0)), + timezone = "America/New_York", + ) + // 20:00 New York is 02:00 the NEXT day in Berlin. + assertThat(form.startInstant(form.resolvedZone(berlin)).toLocalDateTime(berlin)) + .isEqualTo(LocalDateTime(LocalDate(2026, 7, 18), LocalTime(2, 0))) + } + + @Test + fun `a pinned event's local time tracks each zone's own DST`() { + // Late March the US has sprung forward and Europe hasn't: five hours, not six. + val form = EventForm( + calendarId = 1L, + start = LocalDateTime(LocalDate(2026, 3, 20), LocalTime(8, 0)), + end = LocalDateTime(LocalDate(2026, 3, 20), LocalTime(9, 0)), + timezone = "America/New_York", + ) + assertThat(form.startInstant(form.resolvedZone(berlin)).toLocalDateTime(berlin)) + .isEqualTo(LocalDateTime(LocalDate(2026, 3, 20), LocalTime(13, 0))) + } + + @Test + fun `offsets are kept only for a repeated hour`() { + val summer = form().withTimes( + Instant.parse("2026-06-11T08:00:00Z"), + Instant.parse("2026-06-11T09:00:00Z"), + berlin, + ) + assertThat(summer.startOffset).isNull() + assertThat(summer.endOffset).isNull() + val repeated = form().withTimes( + Instant.parse("2026-10-25T00:45:00Z"), + Instant.parse("2026-10-25T01:15:00Z"), + berlin, + ) + assertThat(repeated.startOffset).isEqualTo(UtcOffset(hours = 2)) + assertThat(repeated.endOffset).isEqualTo(UtcOffset(hours = 1)) + } + + @Test + fun `an end changed and changed back is the form it was`() { + val original = detail().toEditForm( + Instant.parse("2026-06-11T08:00:00Z").toEpochMilliseconds(), + Instant.parse("2026-06-11T09:00:00Z").toEpochMilliseconds(), + berlin, + ) + val back = original + .withEnd(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 0))) + .withEnd(original.end) + assertThat(back).isEqualTo(original) + } + + @Test + fun `a new zone drops the kept pass, the same zone keeps it`() { + val repeated = form().withTimes( + Instant.parse("2026-10-25T01:30:00Z"), + Instant.parse("2026-10-25T02:00:00Z"), + berlin, + ) + assertThat(repeated.withTimezone("Europe/Berlin", berlin)).isEqualTo(repeated.copy(timezone = "Europe/Berlin")) + val moved = repeated.withTimezone("Africa/Lagos", berlin) + assertThat(moved.startOffset).isNull() + assertThat(moved.endOffset).isNull() + // Back on Berlin, a picked 02:30 takes the earlier pass, as any picked time does. + assertThat(moved.withTimezone(null, berlin).startInstant(berlin)) + .isEqualTo(Instant.parse("2026-10-25T00:30:00Z")) + } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventShiftTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventShiftTest.kt index d26a5e3..fb8b8f3 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventShiftTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventShiftTest.kt @@ -232,4 +232,22 @@ class EventShiftTest { val summer = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(14, 0)) assertThat(summer.resolveIn(berlin, cet)).isEqualTo(summer.toInstant(berlin)) } + + @Test + fun `a day move onto the repeated hour doesn't depend on the source's season`() { + // 02:30 a week after the fall-back day (CET) and in summer (CEST), both + // moved onto it: the earlier pass either way, as a timeline drop. + val first = Instant.parse("2026-10-25T00:30:00Z") + val november = form().withTimes( + Instant.parse("2026-11-01T01:30:00Z"), + Instant.parse("2026-11-01T02:30:00Z"), + berlin, + ) + assertThat(november.shiftedByDays(-7, berlin).startInstant(berlin)).isEqualTo(first) + val summer = form( + start = LocalDateTime(LocalDate(2026, 10, 18), LocalTime(2, 30)), + end = LocalDateTime(LocalDate(2026, 10, 18), LocalTime(3, 30)), + ) + assertThat(summer.shiftedByDays(7, berlin).startInstant(berlin)).isEqualTo(first) + } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineDropTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineDropTest.kt index e9cf383..cffdf5c 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineDropTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineDropTest.kt @@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.ui.common import com.google.common.truth.Truth.assertThat import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.domain.repeatedHourOffsetAt import de.jeanlucmakiola.calendula.ui.week.TimedBlock import de.jeanlucmakiola.calendula.ui.week.layoutDay import de.jeanlucmakiola.calendula.ui.week.minutesPast @@ -216,7 +217,7 @@ class TimelineDropTest { @Test fun `a start in the spring-forward gap resolves an hour on`() { val springForward = LocalDate(2026, 3, 29) - val landed = wallClockInstant(springForward, 2 * 60 + 30, brussels, keepOffsetOf = overnight.start) + val landed = wallClockInstant(springForward, 2 * 60 + 30, brussels, kept = brussels.repeatedHourOffsetAt(overnight.start)) assertThat(landed.minutesPast(springForward, brussels)).isEqualTo(3 * 60 + 30) assertThat(wallClockSpanMinutes(springForward, 3 * 60 + 30, 1.hours, brussels)).isEqualTo(60) } @@ -234,10 +235,34 @@ class TimelineDropTest { val first = Instant.parse("2026-10-25T00:30:00Z") val july = Instant.parse("2026-07-01T10:00:00Z") val november = Instant.parse("2026-11-10T10:00:00Z") - assertThat(wallClockInstant(fallBack, slot, brussels, keepOffsetOf = july)).isEqualTo(first) - assertThat(wallClockInstant(fallBack, slot, brussels, keepOffsetOf = november)).isEqualTo(first) + assertThat(wallClockInstant(fallBack, slot, brussels, kept = brussels.repeatedHourOffsetAt(july))).isEqualTo(first) + assertThat(wallClockInstant(fallBack, slot, brussels, kept = brussels.repeatedHourOffsetAt(november))).isEqualTo(first) // Later the same day, after the clocks went back: still no say. val evening = Instant.parse("2026-10-25T18:00:00Z") - assertThat(wallClockInstant(fallBack, slot, brussels, keepOffsetOf = evening)).isEqualTo(first) + assertThat(wallClockInstant(fallBack, slot, brussels, kept = brussels.repeatedHourOffsetAt(evening))).isEqualTo(first) + } + + @Test + fun `a bottom resize into the repeated hour keeps a second-pass start's pass`() { + // 02:30 CET (the second pass) to 04:00, its end pulled up to 02:45. + val event = overnight.copy( + start = Instant.parse("2026-10-25T01:30:00Z"), + end = Instant.parse("2026-10-25T03:00:00Z"), + ) + val drop = resizedDrop(event, ResizeEdge.Bottom, fallBack, 2 * 60 + 30, 15, brussels) + assertThat(drop.start).isEqualTo(event.start) + assertThat(drop.end).isEqualTo(Instant.parse("2026-10-25T01:45:00Z")) + } + + @Test + fun `a resized edge keeps its own pass over the other edge's`() { + // 02:30 CEST (the first pass) to 02:15 CET (the second), the start pulled to 02:00. + val event = overnight.copy( + start = Instant.parse("2026-10-25T00:30:00Z"), + end = Instant.parse("2026-10-25T01:15:00Z"), + ) + val drop = resizedDrop(event, ResizeEdge.Top, fallBack, 2 * 60, 60, brussels) + assertThat(drop.start).isEqualTo(Instant.parse("2026-10-25T00:00:00Z")) + assertThat(drop.end).isEqualTo(event.end) } }