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.
This commit is contained in:
2026-10-14 14:02:12 +02:00
parent 04396529b7
commit 8c305ffa55
12 changed files with 275 additions and 107 deletions
@@ -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(),
@@ -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
@@ -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)
}
@@ -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(),
@@ -239,16 +239,16 @@ fun CalendarHost(
var heldCreateIso by remember { mutableStateOf<String?>(null) }
var createStartMinutes by rememberSaveable { mutableStateOf<Int?>(null) }
var heldCreateMinutes by remember { mutableStateOf<Int?>(null) }
// A drafted block's end (#153), in minutes from the start day's midnight.
var createEndMinutes by rememberSaveable { mutableStateOf<Int?>(null) }
var heldCreateEndMinutes by remember { mutableStateOf<Int?>(null) }
val onCreateSpan: (LocalDate, Int?, Int?) -> Unit = { date, startMinutes, endMinutes ->
// A drafted block's length (#153), in minutes.
var createDurationMinutes by rememberSaveable { mutableStateOf<Int?>(null) }
var heldCreateDurationMinutes by remember { mutableStateOf<Int?>(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 },
@@ -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 {
@@ -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
@@ -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) } }
}
@@ -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.
@@ -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"))
}
}
@@ -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)
}
}
@@ -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)
}
}