Carry each form time's repeated-hour pass with it (#387)

The event form held its times and their UTC offsets as four separate
fields, so any change to a time had to remember to refresh or clear its
offset. A form time is now one FormTime: the wall clock plus the offset it
was read at when the fall-back hour repeats it. Setting a time sets both, so
the two can't drift apart. Wall-clock meaning is unchanged: an unpinned form
still follows the device zone, a zone switch keeps the wall clock and
all-day forms stay dates.

- A pass switch now shows up in the form itself, so saving no longer
  compares the written times separately.
- The editor's view model reads the device zone through a seam, and tests
  cover a draft opened across the fall-back hour and in the spring-forward
  gap.
This commit is contained in:
2026-10-14 14:41:51 +02:00
parent 8c305ffa55
commit d015fe568c
15 changed files with 261 additions and 204 deletions
@@ -1,12 +1,13 @@
package de.jeanlucmakiola.calendula.data.calendar
import de.jeanlucmakiola.calendula.domain.resolveIn
import de.jeanlucmakiola.calendula.domain.instantIn
import kotlinx.datetime.toKotlinTimeZone
import android.provider.CalendarContract
import de.jeanlucmakiola.calendula.domain.AccessLevel
import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.FormTime
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import de.jeanlucmakiola.calendula.domain.nearestTo
@@ -50,8 +51,8 @@ internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDa
} else {
val writeZone = writeZone(zone)
EventWriteTimes(
dtStartMillis = start.resolveIn(writeZone.toKotlinTimeZone(), startOffset).toEpochMilliseconds(),
dtEndMillis = end.resolveIn(writeZone.toKotlinTimeZone(), endOffset).toEpochMilliseconds(),
dtStartMillis = start.instantIn(writeZone.toKotlinTimeZone()).toEpochMilliseconds(),
dtEndMillis = end.instantIn(writeZone.toKotlinTimeZone()).toEpochMilliseconds(),
timezone = writeZone.id,
)
}
@@ -77,7 +78,7 @@ private fun EventForm.writeZone(deviceZone: ZoneId): ZoneId = if (isAllDay) {
private fun EventForm.anchorLocal(): JavaLocalDateTime = if (isAllDay) {
start.date.toJavaLocalDate().atStartOfDay()
} else {
start.toJavaLocalDateTime()
start.dateTime.toJavaLocalDateTime()
}
/**
@@ -184,13 +185,12 @@ internal fun buildEventUpdateValues(
// A zone change counts as a time change even when the wall-clock is
// untouched: the same 09:00 in another zone is a different instant, so
// DTSTART has to move with it. So does a switch between the two passes of
// the fall-back hour, which only the resolved instants tell apart (#387).
// the fall-back hour, which each [FormTime] carries (#387).
val timesChanged = updated.start != original.start ||
updated.end != original.end ||
updated.isAllDay != original.isAllDay ||
updated.rrule != original.rrule ||
updated.timezone != original.timezone ||
updated.toWriteTimes(zone) != original.toWriteTimes(zone)
updated.timezone != original.timezone
if (!timesChanged) return@buildMap
val newTimes = updated.toWriteTimes(zone)
@@ -8,6 +8,7 @@ import de.jeanlucmakiola.floret.reminders.ReminderOverride
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.FormTime
import de.jeanlucmakiola.calendula.domain.RecurrenceFreq
import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate
@@ -212,8 +213,8 @@ class SpecialDatesSyncEngine @Inject constructor(
calendarId = calendarId,
title = title,
isAllDay = true,
start = start,
end = start,
start = FormTime(start),
end = FormTime(start),
reminders = reminderCtx.resolveAllDay(calendarId),
availability = Availability.Free,
rrule = YEARLY_RRULE,
@@ -17,8 +17,8 @@ data class EventForm(
val calendarId: Long?,
val title: String = "",
val isAllDay: Boolean = false,
val start: LocalDateTime,
val end: LocalDateTime,
val start: FormTime,
val end: FormTime,
/**
* The zone [start]/[end] are wall-clock values in, or null to follow the
* device — null is not "no zone", it is "whichever zone the device is in
@@ -36,14 +36,6 @@ data class EventForm(
* (see [EventFormField.Timezone] and the data layer's UTC-midnight rule).
*/
val timezone: String? = null,
/**
* 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,
val location: String = "",
val description: String = "",
/** Reminder lead times in minutes before the start, deduplicated. */
@@ -78,6 +70,19 @@ data class EventForm(
val attendees: List<EventAttendee> = emptyList(),
)
/**
* One end of a form's span: a wall-clock [dateTime] in the form's zone, and the
* [offset] it was read at when the fall-back hour repeats that wall clock — both
* of the hour's instants share one [LocalDateTime] (#387). Null everywhere else,
* so a time changed and changed back compares equal again, and for a time picked
* by hand, which can't say which pass it means and takes the earlier one. One
* value, so a time never changes without its pass. See [toFormTime].
*/
data class FormTime(val dateTime: LocalDateTime, val offset: UtcOffset? = null) {
val date: LocalDate get() = dateTime.date
val time: LocalTime get() = dateTime.time
}
/**
* One editable guest: the user controls the [email] (the identity we dedup and
* write on), an optional display [name], and whether they're [optional] rather
@@ -159,9 +164,9 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
val startDate = begin.toLocalDateTime(TimeZone.UTC).date
val endExclusive = finish.toLocalDateTime(TimeZone.UTC).date
val endDate = maxOf(startDate, LocalDate.fromEpochDays(endExclusive.toEpochDays() - 1))
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
FormTime(LocalDateTime(startDate, LocalTime(9, 0))) to FormTime(LocalDateTime(endDate, LocalTime(10, 0)))
} else {
begin.toLocalDateTime(formZone) to finish.toLocalDateTime(formZone)
begin.toFormTime(formZone) to finish.toFormTime(formZone)
}
return EventForm(
calendarId = instance.calendarId,
@@ -170,8 +175,6 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
start = start,
end = end,
timezone = pinnedZone,
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(),
@@ -37,23 +37,25 @@ fun Instant.inRepeatedHour(zone: TimeZone): Boolean {
/**
* [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).
* What a [FormTime.offset] holds (#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)
/** This instant as a [FormTime] in [zone], keeping its pass of a repeated hour. */
fun Instant.toFormTime(zone: TimeZone): FormTime = FormTime(toLocalDateTime(zone), zone.repeatedHourOffsetAt(this))
/** The form's end as an instant in [zone], keeping its [EventForm.endOffset]. */
fun EventForm.endInstant(zone: TimeZone): Instant = end.resolveIn(zone, endOffset)
/** This time as an instant in [zone], on its kept pass of a repeated hour. */
fun FormTime.instantIn(zone: TimeZone): Instant = dateTime.resolveIn(zone, offset)
/** The form's timed span set to these instants, read in [zone] with their offsets. */
fun EventForm.withTimes(start: Instant, end: Instant, zone: TimeZone): EventForm = copy(
start = start.toLocalDateTime(zone),
end = end.toLocalDateTime(zone),
startOffset = zone.repeatedHourOffsetAt(start),
endOffset = zone.repeatedHourOffsetAt(end),
)
/** The form's start as an instant in [zone]. */
fun EventForm.startInstant(zone: TimeZone): Instant = start.instantIn(zone)
/** The form's end as an instant in [zone]. */
fun EventForm.endInstant(zone: TimeZone): Instant = end.instantIn(zone)
/** The form's timed span set to these instants, read in [zone]. */
fun EventForm.withTimes(start: Instant, end: Instant, zone: TimeZone): EventForm =
copy(start = start.toFormTime(zone), end = end.toFormTime(zone))
/**
* The form pinned to [zoneId] (null: the device). A kept pass of a repeated hour
@@ -62,16 +64,16 @@ fun EventForm.withTimes(start: Instant, end: Instant, zone: TimeZone): EventForm
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)
return pinned.copy(start = FormTime(start.dateTime), end = FormTime(end.dateTime))
}
/**
* 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
* the earlier — unless it is the end the form already has.
* pass of a repeated hour it means, so it takes the earlier — unless it is the
* end the form already has.
*/
fun EventForm.withEnd(end: LocalDateTime): EventForm =
if (end == this.end) this else copy(end = end, endOffset = null)
if (end == this.end.dateTime) this else copy(end = FormTime(end))
/**
* The form moved so it starts at [newStart], keeping its **instant** duration —
@@ -98,13 +100,13 @@ fun EventForm.shiftedByDays(days: Int, deviceZone: TimeZone): EventForm {
val newStart = LocalDateTime(start.date.plus(days, DateTimeUnit.DAY), start.time)
if (isAllDay) {
return copy(
start = newStart,
end = LocalDateTime(end.date.plus(days, DateTimeUnit.DAY), end.time),
start = FormTime(newStart),
end = FormTime(LocalDateTime(end.date.plus(days, DateTimeUnit.DAY), end.time)),
)
}
val zone = resolvedZone(deviceZone)
val span = endInstant(zone) - startInstant(zone)
val startAt = newStart.resolveIn(zone, startOffset)
val startAt = FormTime(newStart, start.offset).instantIn(zone)
return withTimes(startAt, startAt + span, zone)
}
@@ -34,15 +34,7 @@ fun buildInsertEventForm(
zone: TimeZone,
now: Instant,
): EventForm {
val timed = if (isAllDay) {
null
} else {
val startAt = beginMillis?.let(Instant::fromEpochMilliseconds) ?: nextFullHour(now)
val endAt = endMillis?.let(Instant::fromEpochMilliseconds)?.takeIf { it >= startAt }
?: (startAt + 1.hours)
startAt to endAt
}
val (start, end) = if (timed == null) {
val (start, end) = if (isAllDay) {
// All-day provider times are UTC midnights with an exclusive end; show
// the last covered day and keep placeholder wall-clock times in case the
// user switches the event to timed (mirrors EventDetail.toEditForm).
@@ -53,9 +45,12 @@ fun buildInsertEventForm(
?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.UTC).date }
?.let { exclusive -> maxOf(startDate, LocalDate.fromEpochDays(exclusive.toEpochDays() - 1)) }
?: startDate
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
FormTime(LocalDateTime(startDate, LocalTime(9, 0))) to FormTime(LocalDateTime(endDate, LocalTime(10, 0)))
} else {
timed.first.toLocalDateTime(zone) to timed.second.toLocalDateTime(zone)
val startAt = beginMillis?.let(Instant::fromEpochMilliseconds) ?: nextFullHour(now)
val endAt = endMillis?.let(Instant::fromEpochMilliseconds)?.takeIf { it >= startAt }
?: (startAt + 1.hours)
startAt.toFormTime(zone) to endAt.toFormTime(zone)
}
return EventForm(
calendarId = null,
@@ -63,8 +58,6 @@ fun buildInsertEventForm(
isAllDay = isAllDay,
start = start,
end = end,
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:"
@@ -1,7 +1,8 @@
package de.jeanlucmakiola.calendula.domain.ics
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.repeatedHourOffsetAt
import de.jeanlucmakiola.calendula.domain.FormTime
import de.jeanlucmakiola.calendula.domain.toFormTime
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
@@ -25,9 +26,9 @@ fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm {
val startDate = this.start.toLocalDateTime(TimeZone.UTC).date
val endExclusive = this.end.toLocalDateTime(TimeZone.UTC).date
val endDate = maxOf(startDate, LocalDate.fromEpochDays(endExclusive.toEpochDays() - 1))
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
FormTime(LocalDateTime(startDate, LocalTime(9, 0))) to FormTime(LocalDateTime(endDate, LocalTime(10, 0)))
} else {
this.start.toLocalDateTime(zone) to this.end.toLocalDateTime(zone)
this.start.toFormTime(zone) to this.end.toFormTime(zone)
}
return EventForm(
calendarId = null,
@@ -35,8 +36,6 @@ fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm {
isAllDay = isAllDay,
start = start,
end = 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(),
@@ -668,11 +668,11 @@ private fun EventEditContent(
) {
Spacer(Modifier.height(4.dp))
val formZone = remember(form.timezone) { form.resolvedZone(TimeZone.currentSystemDefault()) }
val startAt = remember(form.start, form.startOffset, formZone) { form.startInstant(formZone) }
val endAt = remember(form.end, form.endOffset, formZone) { form.endInstant(formZone) }
val startAt = remember(form.start, formZone) { form.startInstant(formZone) }
val endAt = remember(form.end, formZone) { form.endInstant(formZone) }
ScheduleRow(
label = stringResource(R.string.event_edit_starts),
dateTime = form.start,
dateTime = form.start.dateTime,
instant = startAt,
zone = formZone,
isAllDay = form.isAllDay,
@@ -683,7 +683,7 @@ private fun EventEditContent(
)
ScheduleRow(
label = stringResource(R.string.event_edit_ends),
dateTime = form.end,
dateTime = form.end.dateTime,
instant = endAt,
zone = formZone,
isAllDay = form.isAllDay,
@@ -1,6 +1,7 @@
package de.jeanlucmakiola.calendula.ui.edit
import android.util.Log
import androidx.annotation.VisibleForTesting
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -21,7 +22,9 @@ 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.instantIn
import de.jeanlucmakiola.calendula.domain.toFormTime
import de.jeanlucmakiola.calendula.domain.FormTime
import de.jeanlucmakiola.calendula.domain.resolvedZone
import de.jeanlucmakiola.calendula.domain.startInstant
import de.jeanlucmakiola.calendula.domain.withEnd
@@ -142,6 +145,10 @@ class EventEditViewModel @Inject constructor(
@IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() {
/** The zone the device is in, read fresh each time; tests pin it. */
@VisibleForTesting
internal var deviceZone: () -> TimeZone = { TimeZone.currentSystemDefault() }
private val _form = MutableStateFlow<EventForm?>(null)
private val _saveState = MutableStateFlow<SaveUiState>(SaveUiState.Idle)
// Problems stay hidden until the first save attempt, so a half-filled
@@ -297,7 +304,7 @@ class EventEditViewModel @Inject constructor(
form = resolved,
calendars = pickerCalendars,
problems = if (local.showProblems) {
resolved.problems(TimeZone.currentSystemDefault())
resolved.problems(deviceZone())
} else {
emptySet()
},
@@ -344,7 +351,7 @@ class EventEditViewModel @Inject constructor(
*/
fun openNew(date: LocalDate, startMinutes: Int? = null, durationMinutes: Int? = null) {
if (_form.value != null) return
val zone = TimeZone.currentSystemDefault()
val zone = deviceZone()
val now = Clock.System.now()
val startAt = when {
startMinutes != null ->
@@ -354,8 +361,7 @@ class EventEditViewModel @Inject constructor(
}
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)
_form.value = EventForm(calendarId = null, start = startAt.toFormTime(zone), end = endAt.toFormTime(zone))
if (chosen != null) _durationTouched.value = true
applyDefaultReminder()
applyDefaultDuration()
@@ -467,7 +473,7 @@ class EventEditViewModel @Inject constructor(
if (_form.value?.isAllDay != false) return@launch
val duration = resolveDefaultEventDuration(global, overrides, targetId)
update { form ->
val zone = form.resolvedZone(TimeZone.currentSystemDefault())
val zone = form.resolvedZone(deviceZone())
val start = form.startInstant(zone)
form.withTimes(start, start + duration.minutes, zone)
}
@@ -530,7 +536,7 @@ class EventEditViewModel @Inject constructor(
_loadFailed.value = true
return@launch
}
val zone = TimeZone.currentSystemDefault()
val zone = deviceZone()
val begin = beginMillis.takeUnless { it == NO_OCCURRENCE_TIME }
?: detail.instance.start.toEpochMilliseconds()
val end = endMillis.takeUnless { it == NO_OCCURRENCE_TIME }
@@ -583,8 +589,11 @@ class EventEditViewModel @Inject constructor(
// which is the same thing a fresh event gets.
// 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)
if (value) {
it.copy(isAllDay = true, timezone = null, start = FormTime(it.start.dateTime), end = FormTime(it.end.dateTime))
} 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).
@@ -600,7 +609,7 @@ class EventEditViewModel @Inject constructor(
* time — only real picks are remembered, not the device fallback.
*/
fun setTimezone(zoneId: String?) {
update { it.withTimezone(zoneId, TimeZone.currentSystemDefault()) }
update { it.withTimezone(zoneId, deviceZone()) }
if (zoneId != null) {
viewModelScope.launch { withContext(io) { settingsPrefs.addRecentTimeZone(zoneId) } }
}
@@ -696,7 +705,7 @@ class EventEditViewModel @Inject constructor(
val current = state.value ?: return
if (current.saveState == SaveUiState.Saving) return
val form = current.form
if (form.problems(TimeZone.currentSystemDefault()).isNotEmpty()) {
if (form.problems(deviceZone()).isNotEmpty()) {
_showProblems.value = true
return
}
@@ -825,12 +834,12 @@ class EventEditViewModel @Inject constructor(
}
private fun moveStart(transform: (LocalDateTime) -> LocalDateTime) = update { form ->
val zone = form.resolvedZone(TimeZone.currentSystemDefault())
val zone = form.resolvedZone(deviceZone())
val duration = form.endInstant(zone) - form.startInstant(zone)
// A picked time can't say which pass of a repeated hour it means; only
// an unchanged start keeps its own (see [withEnd]).
val picked = transform(form.start)
val newStart = picked.resolveIn(zone, form.startOffset.takeIf { picked == form.start })
val picked = transform(form.start.dateTime)
val newStart = (if (picked == form.start.dateTime) form.start else FormTime(picked)).instantIn(zone)
form.withTimes(newStart, newStart + duration, zone)
}
@@ -11,6 +11,7 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FormTime
import de.jeanlucmakiola.calendula.domain.SearchCandidate
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
@@ -510,8 +511,8 @@ class CalendarRepositoryImplTest {
val form = EventForm(
calendarId = 1L,
title = "Stand-up",
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 15)),
start = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 15))),
)
val id = repo.createEvent(form)
@@ -531,8 +532,8 @@ class CalendarRepositoryImplTest {
val form = EventForm(
calendarId = 1L,
isAllDay = true,
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(0, 0)),
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(0, 0)),
start = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(0, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(0, 0))),
)
repo.createEvent(form)
@@ -548,8 +549,8 @@ class CalendarRepositoryImplTest {
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
val form = EventForm(
calendarId = 1L,
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0)),
start = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0))),
)
try {
@@ -567,8 +568,8 @@ class CalendarRepositoryImplTest {
val original = EventForm(
calendarId = 1L,
title = "Stand-up",
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 15)),
start = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 15))),
)
val updated = original.copy(title = "Daily")
@@ -584,8 +585,8 @@ class CalendarRepositoryImplTest {
val original = EventForm(
calendarId = 1L,
title = "Stand-up",
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 15)),
start = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 15))),
)
val updated = original.copy(calendarId = 3L)
@@ -605,8 +606,8 @@ class CalendarRepositoryImplTest {
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
val form = EventForm(
calendarId = 1L,
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0)),
start = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0))),
)
try {
@@ -625,8 +626,8 @@ class CalendarRepositoryImplTest {
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
val form = EventForm(
calendarId = 1L,
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0)),
start = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0))),
)
try {
@@ -678,8 +679,8 @@ class CalendarRepositoryImplTest {
val form = EventForm(
calendarId = 1L,
title = "Moved",
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0)),
start = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0))),
)
val id = repo.updateOccurrence(
@@ -701,8 +702,8 @@ class CalendarRepositoryImplTest {
val original = EventForm(
calendarId = 1L,
title = "Weekly",
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0)),
start = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0))),
rrule = "FREQ=WEEKLY",
)
val updated = original.copy(title = "Weekly, renamed")
@@ -5,6 +5,7 @@ import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.AccessLevel
import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.FormTime
import de.jeanlucmakiola.calendula.domain.realignRecurrence
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
@@ -26,8 +27,8 @@ class EventWriteMapperTest {
): EventForm = EventForm(
calendarId = 1L,
isAllDay = isAllDay,
start = start,
end = end,
start = FormTime(start),
end = FormTime(end),
timezone = timezone,
)
@@ -156,8 +157,8 @@ class EventWriteMapperTest {
assertThat(values).doesNotContainKey(CalendarContract.Events.EVENT_TIMEZONE)
val moved = original.copy(
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0)),
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 30)),
start = FormTime(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 30))),
)
assertThat(update(original, moved)[CalendarContract.Events.EVENT_TIMEZONE])
.isEqualTo("America/New_York")
@@ -203,8 +204,8 @@ class EventWriteMapperTest {
fun `time edit on a one-off event writes absolute times and clears recurrence columns`() {
val original = form()
val updated = original.copy(
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0)),
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 0)),
start = FormTime(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 0))),
)
val values = update(original, updated)
// 2026-06-11 11:00 CEST == 09:00Z.
@@ -223,8 +224,8 @@ class EventWriteMapperTest {
val original = form().copy(rrule = "FREQ=WEEKLY")
val updated = original.copy(
// Pushed one hour later than the displayed occurrence.
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0)),
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 30)),
start = FormTime(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 30))),
)
val values = update(original, updated)
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(seriesStart + 3_600_000L)
@@ -265,8 +266,8 @@ class EventWriteMapperTest {
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)),
).copy(rrule = "FREQ=WEEKLY")
val moved = original.copy(
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)),
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(11, 0)),
start = FormTime(LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 7, 15), LocalTime(11, 0))),
)
assertThat(update(original, moved, series)[CalendarContract.Events.DTSTART])
@@ -281,8 +282,8 @@ class EventWriteMapperTest {
end = LocalDateTime(LocalDate(2026, 1, 7), LocalTime(10, 0)),
).copy(rrule = "FREQ=WEEKLY")
val moved = original.copy(
start = LocalDateTime(LocalDate(2026, 1, 9), LocalTime(14, 30)),
end = LocalDateTime(LocalDate(2026, 1, 9), LocalTime(15, 30)),
start = FormTime(LocalDateTime(LocalDate(2026, 1, 9), LocalTime(14, 30))),
end = FormTime(LocalDateTime(LocalDate(2026, 1, 9), LocalTime(15, 30))),
)
assertThat(update(original, moved, series)[CalendarContract.Events.DTSTART])
@@ -299,8 +300,8 @@ class EventWriteMapperTest {
end = LocalDateTime(LocalDate(2026, 6, 8), LocalTime(10, 0)),
).copy(rrule = "FREQ=WEEKLY;BYDAY=MO")
val moved = original.copy(
start = LocalDateTime(LocalDate(2026, 6, 10), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 6, 10), LocalTime(10, 0)),
start = FormTime(LocalDateTime(LocalDate(2026, 6, 10), LocalTime(9, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 6, 10), LocalTime(10, 0))),
rrule = realignRecurrence("FREQ=WEEKLY;BYDAY=MO", LocalDate(2026, 6, 8), LocalDate(2026, 6, 10)),
)
@@ -321,8 +322,8 @@ class EventWriteMapperTest {
end = LocalDateTime(LocalDate(2026, 1, 7), LocalTime(10, 30)),
).copy(rrule = "FREQ=WEEKLY")
val moved = original.copy(
start = LocalDateTime(LocalDate(2026, 1, 8), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 1, 8), LocalTime(10, 30)),
start = FormTime(LocalDateTime(LocalDate(2026, 1, 8), LocalTime(9, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 1, 8), LocalTime(10, 30))),
)
assertThat(update(original, moved, series)[CalendarContract.Events.DURATION])
@@ -333,8 +334,8 @@ class EventWriteMapperTest {
fun `shifting a one-off event and back again restores the original values`() {
val original = form()
val moved = original.copy(
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(14, 0)),
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(15, 0)),
start = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(14, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(15, 0))),
)
val forward = update(original, moved)
@@ -359,8 +360,8 @@ class EventWriteMapperTest {
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)),
).copy(rrule = "FREQ=WEEKLY")
val moved = original.copy(
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(14, 30)),
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(15, 30)),
start = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(14, 30))),
end = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(15, 30))),
)
val movedAnchor = update(original, moved, series)[CalendarContract.Events.DTSTART] as Long
@@ -680,8 +681,8 @@ class EventWriteMapperTest {
/** [julySeries] pushed to [hour]:00, the shift an "all events" time edit makes. */
private fun EventForm.atHour(hour: Int): EventForm = copy(
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(hour, 0)),
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(hour + 1, 0)),
start = FormTime(LocalDateTime(LocalDate(2026, 7, 15), LocalTime(hour, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 7, 15), LocalTime(hour + 1, 0))),
)
@Test
@@ -730,8 +731,8 @@ class EventWriteMapperTest {
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
).copy(rrule = "FREQ=WEEKLY")
val moved = original.copy(
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
start = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0))),
)
assertThat(update(original, moved, series, "20260722")[CalendarContract.Events.EXDATE])
@@ -750,8 +751,8 @@ class EventWriteMapperTest {
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
).copy(rrule = "FREQ=WEEKLY")
val moved = original.copy(
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
start = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0))),
)
val values = update(original, moved, series, "20260722T000000Z")
@@ -770,8 +771,8 @@ class EventWriteMapperTest {
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
).copy(rrule = "FREQ=WEEKLY")
val moved = original.copy(
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
start = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0))),
)
val values = update(original, moved, series, "20260722T000000")
@@ -789,8 +790,8 @@ class EventWriteMapperTest {
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
).copy(rrule = "FREQ=WEEKLY")
val moved = original.copy(
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
start = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0))),
)
val values = update(original, moved, series, "20260722T090000Z")
@@ -817,8 +818,8 @@ class EventWriteMapperTest {
).copy(rrule = "FREQ=WEEKLY")
val timed = original.copy(
isAllDay = false,
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)),
start = FormTime(LocalDateTime(LocalDate(2026, 7, 15), LocalTime(9, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0))),
)
// The excluded day gains the new 09:00 Berlin time-of-day == 07:00Z.
assertThat(update(original, timed, series, "20260722")[CalendarContract.Events.EXDATE])
@@ -900,8 +901,8 @@ class EventWriteMapperTest {
val series = instantAt("2026-01-07T09:00", "Europe/Berlin")
val original = julySeries()
val moved = original.copy(
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(14, 30)),
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(15, 30)),
start = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(14, 30))),
end = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(15, 30))),
)
val exdate = "20260722T070000Z"
@@ -1394,7 +1395,7 @@ class EventWriteMapperTest {
fun `a time in the repeated fall-back hour is written at its own offset`() {
val repeated = LocalDateTime(LocalDate(2026, 10, 25), LocalTime(2, 0))
val times = form(start = repeated, end = repeated)
.copy(startOffset = UtcOffset(hours = 2), endOffset = UtcOffset(hours = 1))
.copy(start = FormTime(repeated, UtcOffset(hours = 2)), end = FormTime(repeated, UtcOffset(hours = 1)))
.toWriteTimes(berlin)
assertThat(times.dtStartMillis).isEqualTo(Instant.parse("2026-10-25T00:00:00Z").toEpochMilliseconds())
assertThat(times.dtEndMillis).isEqualTo(Instant.parse("2026-10-25T01:00:00Z").toEpochMilliseconds())
@@ -1405,8 +1406,11 @@ class EventWriteMapperTest {
// 02:30 CEST to 02:30 CET: the same wall clock, an hour later.
val repeated = LocalDateTime(LocalDate(2026, 10, 25), LocalTime(2, 30))
val original = form(start = repeated, end = repeated)
.copy(startOffset = UtcOffset(hours = 2), endOffset = UtcOffset(hours = 2))
val updated = original.copy(startOffset = UtcOffset(hours = 1), endOffset = UtcOffset(hours = 1))
.copy(start = FormTime(repeated, UtcOffset(hours = 2)), end = FormTime(repeated, UtcOffset(hours = 2)))
val updated = original.copy(
start = original.start.copy(offset = UtcOffset(hours = 1)),
end = original.end.copy(offset = UtcOffset(hours = 1)),
)
val values = update(original, updated)
assertThat(values[CalendarContract.Events.DTSTART])
.isEqualTo(Instant.parse("2026-10-25T01:30:00Z").toEpochMilliseconds())
@@ -1418,7 +1422,7 @@ class EventWriteMapperTest {
fun `an untouched form in the repeated fall-back hour writes no times`() {
val repeated = LocalDateTime(LocalDate(2026, 10, 25), LocalTime(2, 30))
val original = form(start = repeated, end = repeated)
.copy(startOffset = UtcOffset(hours = 2), endOffset = UtcOffset(hours = 1))
.copy(start = FormTime(repeated, UtcOffset(hours = 2)), end = FormTime(repeated, UtcOffset(hours = 1)))
assertThat(update(original, original.copy(title = "Renamed")))
.doesNotContainKey(CalendarContract.Events.DTSTART)
}
@@ -17,7 +17,7 @@ class EventFormTest {
isAllDay: Boolean = false,
start: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)),
end: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0)),
): EventForm = EventForm(calendarId = calendarId, isAllDay = isAllDay, start = start, end = end)
): EventForm = EventForm(calendarId = calendarId, isAllDay = isAllDay, start = FormTime(start), end = FormTime(end))
@Test
fun `valid timed form has no problems`() {
@@ -152,8 +152,8 @@ class EventFormTest {
endMillis = 1_781_164_800_000L + 90L * 60L * 1000L,
zone = berlin,
)
assertThat(prefilled.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)))
assertThat(prefilled.end).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 30)))
assertThat(prefilled.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)))
assertThat(prefilled.end.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 30)))
assertThat(prefilled.isAllDay).isFalse()
assertThat(prefilled.calendarId).isEqualTo(7L)
assertThat(prefilled.title).isEqualTo("Stand-up")
@@ -184,7 +184,7 @@ class EventFormTest {
assertThat(prefilled.timezone).isEqualTo("America/New_York")
// 08:00Z is 10:00 in Berlin but 04:00 in New York — the form shows the
// event's own wall-clock, which is what a later save re-anchors to.
assertThat(prefilled.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(4, 0)))
assertThat(prefilled.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(4, 0)))
assertThat(prefilled.populatedFields()).contains(EventFormField.Timezone)
}
@@ -208,15 +208,15 @@ class EventFormTest {
)
// A malformed sync row must not be honoured, nor fail the open.
assertThat(prefilled.timezone).isNull()
assertThat(prefilled.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)))
assertThat(prefilled.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)))
}
@Test
fun `isPinnedAwayFrom is false when there is nothing to disambiguate`() {
val base = EventForm(
calendarId = 1L,
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(8, 0)),
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(9, 0)),
start = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(8, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(9, 0))),
)
// Unpinned: the form's times already are the local times.
assertThat(base.isPinnedAwayFrom(berlin)).isFalse()
@@ -392,7 +392,7 @@ class EventFormTest {
val start = Instant.parse("2026-10-25T00:00:00Z")
val end = Instant.parse("2026-10-25T01:00:00Z")
val prefilled = detail().toEditForm(start.toEpochMilliseconds(), end.toEpochMilliseconds(), berlin)
assertThat(prefilled.start).isEqualTo(prefilled.end)
assertThat(prefilled.start.dateTime).isEqualTo(prefilled.end.dateTime)
assertThat(prefilled.startInstant(berlin)).isEqualTo(start)
assertThat(prefilled.endInstant(berlin)).isEqualTo(end)
}
@@ -405,10 +405,10 @@ class EventFormTest {
Instant.parse("2026-10-25T01:15:00Z"),
berlin,
)
assertThat(form.end).isLessThan(form.start)
assertThat(form.end.dateTime).isLessThan(form.start.dateTime)
assertThat(form.problems(berlin)).isEmpty()
// Read as the same pass at both ends, it really does end first.
assertThat(form.copy(startOffset = null, endOffset = null).problems(berlin))
assertThat(form.copy(start = FormTime(form.start.dateTime), end = FormTime(form.end.dateTime)).problems(berlin))
.containsExactly(EventFormProblem.EndBeforeStart)
}
@@ -420,9 +420,9 @@ class EventFormTest {
berlin,
)
// Picking the end it already has keeps its pass.
assertThat(form.withEnd(form.end)).isEqualTo(form)
assertThat(form.withEnd(form.end.dateTime)).isEqualTo(form)
val picked = form.withEnd(LocalDateTime(LocalDate(2026, 10, 25), LocalTime(2, 50)))
assertThat(picked.endOffset).isNull()
assertThat(picked.end.offset).isNull()
assertThat(picked.endInstant(berlin)).isEqualTo(Instant.parse("2026-10-25T00:50:00Z"))
}
@@ -430,8 +430,8 @@ class EventFormTest {
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)),
start = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(8, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(9, 0))),
timezone = "America/New_York",
)
val zone = form.resolvedZone(berlin)
@@ -446,8 +446,8 @@ class EventFormTest {
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)),
start = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(20, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(21, 0))),
timezone = "America/New_York",
)
// 20:00 New York is 02:00 the NEXT day in Berlin.
@@ -460,8 +460,8 @@ class EventFormTest {
// 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)),
start = FormTime(LocalDateTime(LocalDate(2026, 3, 20), LocalTime(8, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 3, 20), LocalTime(9, 0))),
timezone = "America/New_York",
)
assertThat(form.startInstant(form.resolvedZone(berlin)).toLocalDateTime(berlin))
@@ -475,15 +475,15 @@ class EventFormTest {
Instant.parse("2026-06-11T09:00:00Z"),
berlin,
)
assertThat(summer.startOffset).isNull()
assertThat(summer.endOffset).isNull()
assertThat(summer.start.offset).isNull()
assertThat(summer.end.offset).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))
assertThat(repeated.start.offset).isEqualTo(UtcOffset(hours = 2))
assertThat(repeated.end.offset).isEqualTo(UtcOffset(hours = 1))
}
@Test
@@ -495,7 +495,7 @@ class EventFormTest {
)
val back = original
.withEnd(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 0)))
.withEnd(original.end)
.withEnd(original.end.dateTime)
assertThat(back).isEqualTo(original)
}
@@ -508,8 +508,8 @@ class EventFormTest {
)
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()
assertThat(moved.start.offset).isNull()
assertThat(moved.end.offset).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"))
@@ -24,8 +24,8 @@ class EventShiftTest {
calendarId = 1L,
title = "Standup",
isAllDay = isAllDay,
start = start,
end = end,
start = FormTime(start),
end = FormTime(end),
timezone = timezone,
)
@@ -36,8 +36,8 @@ class EventShiftTest {
val moved = original.shiftedTo(target, berlin)
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(14, 30)))
assertThat(moved.end).isEqualTo(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(15, 30)))
assertThat(moved.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(14, 30)))
assertThat(moved.end.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(15, 30)))
}
@Test
@@ -54,8 +54,8 @@ class EventShiftTest {
val moved = original.shiftedTo(target, berlin)
assertThat(moved.timezone).isEqualTo("America/New_York")
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(9, 30)))
assertThat(moved.end).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 30)))
assertThat(moved.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(9, 30)))
assertThat(moved.end.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 30)))
}
@Test
@@ -71,10 +71,10 @@ class EventShiftTest {
// Two real hours from 01:00 CET lands at 04:00 CEST: the hour in
// between does not exist.
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(1, 0)))
assertThat(moved.end).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(4, 0)))
assertThat(moved.end.toInstant(berlin) - moved.start.toInstant(berlin))
.isEqualTo(original.end.toInstant(berlin) - original.start.toInstant(berlin))
assertThat(moved.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(1, 0)))
assertThat(moved.end.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(4, 0)))
assertThat(moved.end.dateTime.toInstant(berlin) - moved.start.dateTime.toInstant(berlin))
.isEqualTo(original.end.dateTime.toInstant(berlin) - original.start.dateTime.toInstant(berlin))
}
@Test
@@ -85,14 +85,14 @@ class EventShiftTest {
val moved = original.shiftedTo(target, berlin)
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(3, 30)))
assertThat(moved.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(3, 30)))
}
@Test
fun `a zero-distance drop leaves the form untouched`() {
// As read from the provider, offsets and all.
val start = form().start.toInstant(berlin)
val original = form().withTimes(start, form().end.toInstant(berlin), berlin)
val start = form().start.dateTime.toInstant(berlin)
val original = form().withTimes(start, form().end.dateTime.toInstant(berlin), berlin)
assertThat(original.shiftedTo(start, berlin)).isEqualTo(original)
}
@@ -103,7 +103,7 @@ class EventShiftTest {
val moved = original.shiftedTo(target, newYork)
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 0)))
assertThat(moved.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 0)))
}
@Test
@@ -129,8 +129,8 @@ class EventShiftTest {
val moved = multiDay.shiftedByDays(-2, berlin)
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 9), LocalTime(22, 0)))
assertThat(moved.end).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(2, 0)))
assertThat(moved.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 9), LocalTime(22, 0)))
assertThat(moved.end.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(2, 0)))
}
@Test
@@ -144,11 +144,11 @@ class EventShiftTest {
val moved = overnight.shiftedByDays(7, berlin)
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 3, 28), LocalTime(22, 0)))
assertThat(moved.end.toInstant(berlin) - moved.start.toInstant(berlin))
.isEqualTo(overnight.end.toInstant(berlin) - overnight.start.toInstant(berlin))
assertThat(moved.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 3, 28), LocalTime(22, 0)))
assertThat(moved.end.dateTime.toInstant(berlin) - moved.start.dateTime.toInstant(berlin))
.isEqualTo(overnight.end.dateTime.toInstant(berlin) - overnight.start.dateTime.toInstant(berlin))
// The wall-clock end therefore lands an hour later than a naive +7 days.
assertThat(moved.end).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(5, 0)))
assertThat(moved.end.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(5, 0)))
}
@Test
@@ -162,10 +162,10 @@ class EventShiftTest {
val original = form()
val end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 30)).toInstant(berlin)
val resized = original.resizedTo(original.start.toInstant(berlin), end, berlin)
val resized = original.resizedTo(original.start.dateTime.toInstant(berlin), end, berlin)
assertThat(resized.start).isEqualTo(original.start)
assertThat(resized.end).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 30)))
assertThat(resized.end.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 30)))
}
@Test
@@ -173,9 +173,9 @@ class EventShiftTest {
val original = form()
val end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(1, 0)).toInstant(berlin)
val resized = original.resizedTo(original.start.toInstant(berlin), end, berlin)
val resized = original.resizedTo(original.start.dateTime.toInstant(berlin), end, berlin)
assertThat(resized.end).isEqualTo(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(1, 0)))
assertThat(resized.end.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(1, 0)))
}
@Test
@@ -188,16 +188,16 @@ class EventShiftTest {
// "15:30 Berlin" is 09:30 in New York.
val start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(15, 30)).toInstant(berlin)
val resized = original.resizedTo(start, original.end.toInstant(newYork), berlin)
val resized = original.resizedTo(start, original.end.dateTime.toInstant(newYork), berlin)
assertThat(resized.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(9, 30)))
assertThat(resized.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(9, 30)))
assertThat(resized.end).isEqualTo(original.end)
}
@Test
fun `an all-day event ignores a resize`() {
val allDay = form(isAllDay = true)
val start = allDay.start.toInstant(berlin)
val start = allDay.start.dateTime.toInstant(berlin)
assertThat(allDay.resizedTo(start, start, berlin)).isEqualTo(allDay)
}
@@ -218,7 +218,7 @@ class EventShiftTest {
fun `a move onto the second repeated hour stays there`() {
val target = Instant.parse("2026-10-25T01:30:00Z")
val moved = form().withTimes(repeatStart, repeatEnd, berlin).shiftedTo(target, berlin)
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 10, 25), LocalTime(2, 30)))
assertThat(moved.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 10, 25), LocalTime(2, 30)))
assertThat(moved.startInstant(berlin)).isEqualTo(target)
}
@@ -53,8 +53,8 @@ class InsertEventFormTest {
)
assertThat(form.calendarId).isNull() // resolves to last-used / first-writable
assertThat(form.isAllDay).isFalse()
assertThat(form.start).isEqualTo(start)
assertThat(form.end).isEqualTo(end)
assertThat(form.start.dateTime).isEqualTo(start)
assertThat(form.end.dateTime).isEqualTo(end)
assertThat(form.title).isEqualTo("Standup")
assertThat(form.description).isEqualTo("Daily")
assertThat(form.location).isEqualTo("Room 1")
@@ -64,8 +64,8 @@ class InsertEventFormTest {
@Test
fun `missing times default to the next full hour and a one-hour duration`() {
val form = build(title = "Quick note")
assertThat(form.start).isEqualTo(LocalDateTime(2026, 1, 15, 11, 0))
assertThat(form.end).isEqualTo(LocalDateTime(2026, 1, 15, 12, 0))
assertThat(form.start.dateTime).isEqualTo(LocalDateTime(2026, 1, 15, 11, 0))
assertThat(form.end.dateTime).isEqualTo(LocalDateTime(2026, 1, 15, 12, 0))
assertThat(form.title).isEqualTo("Quick note")
}
@@ -73,8 +73,8 @@ class InsertEventFormTest {
fun `timed event with only a start gets a one-hour end`() {
val start = LocalDateTime(2026, 5, 2, 9, 0)
val form = build(beginMillis = millis(start))
assertThat(form.start).isEqualTo(start)
assertThat(form.end).isEqualTo(LocalDateTime(2026, 5, 2, 10, 0))
assertThat(form.start.dateTime).isEqualTo(start)
assertThat(form.end.dateTime).isEqualTo(LocalDateTime(2026, 5, 2, 10, 0))
}
@Test
@@ -82,7 +82,7 @@ class InsertEventFormTest {
val start = LocalDateTime(2026, 5, 2, 9, 0)
val badEnd = LocalDateTime(2026, 5, 2, 8, 0)
val form = build(beginMillis = millis(start), endMillis = millis(badEnd))
assertThat(form.end).isEqualTo(LocalDateTime(2026, 5, 2, 10, 0))
assertThat(form.end.dateTime).isEqualTo(LocalDateTime(2026, 5, 2, 10, 0))
}
@Test
@@ -29,8 +29,8 @@ class ParsedIcsFormTest {
assertThat(form.calendarId).isNull()
assertThat(form.title).isEqualTo("Call")
// 13:00 UTC == 15:00 Berlin (summer).
assertThat(form.start).isEqualTo(LocalDateTime(2026, 6, 18, 15, 0, 0))
assertThat(form.end).isEqualTo(LocalDateTime(2026, 6, 18, 16, 0, 0))
assertThat(form.start.dateTime).isEqualTo(LocalDateTime(2026, 6, 18, 15, 0, 0))
assertThat(form.end.dateTime).isEqualTo(LocalDateTime(2026, 6, 18, 16, 0, 0))
assertThat(form.reminders).containsExactly(5, 10).inOrder()
assertThat(form.rrule).isEqualTo("FREQ=WEEKLY")
}
@@ -12,6 +12,10 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FormTime
import de.jeanlucmakiola.calendula.domain.startInstant
import de.jeanlucmakiola.calendula.domain.problems
import de.jeanlucmakiola.calendula.domain.endInstant
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -26,6 +30,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@@ -221,12 +226,12 @@ class EventEditViewModelTest {
advanceUntilIdle()
// Calendar 1 has no length of its own, so it inherits the global 30 min.
assertThat(vm.state.value?.form?.end).isEqualTo(LocalDateTime(2030, 1, 15, 9, 30))
assertThat(vm.state.value?.form?.end?.dateTime).isEqualTo(LocalDateTime(2030, 1, 15, 9, 30))
vm.setCalendar(2L)
advanceUntilIdle()
assertThat(vm.state.value?.form?.end).isEqualTo(LocalDateTime(2030, 1, 15, 17, 0))
assertThat(vm.state.value?.form?.end?.dateTime).isEqualTo(LocalDateTime(2030, 1, 15, 17, 0))
job.cancel()
}
@@ -246,7 +251,7 @@ class EventEditViewModelTest {
vm.setCalendar(2L)
advanceUntilIdle()
assertThat(vm.state.value?.form?.end).isEqualTo(LocalDateTime(2030, 1, 15, 10, 15))
assertThat(vm.state.value?.form?.end?.dateTime).isEqualTo(LocalDateTime(2030, 1, 15, 10, 15))
job.cancel()
}
@@ -267,7 +272,7 @@ class EventEditViewModelTest {
advanceUntilIdle()
// The file owns the length; a calendar switch must not restretch it.
assertThat(vm.state.value?.form?.end).isEqualTo(LocalDateTime(2030, 1, 15, 11, 30))
assertThat(vm.state.value?.form?.end?.dateTime).isEqualTo(LocalDateTime(2030, 1, 15, 11, 30))
job.cancel()
}
@@ -288,15 +293,15 @@ class EventEditViewModelTest {
)
advanceUntilIdle()
assertThat(vm.state.value?.form?.end).isEqualTo(LocalDateTime(2030, 1, 15, 9, 30))
assertThat(vm.state.value?.form?.end?.dateTime).isEqualTo(LocalDateTime(2030, 1, 15, 9, 30))
job.cancel()
}
private fun importedForm(end: LocalDateTime) = EventForm(
calendarId = null,
title = "Imported",
start = LocalDateTime(2030, 1, 15, 9, 0),
end = end,
start = FormTime(LocalDateTime(2030, 1, 15, 9, 0)),
end = FormTime(end),
)
@Test
@@ -316,7 +321,7 @@ class EventEditViewModelTest {
advanceUntilIdle()
// Dates carry no length: the times stay where the form put them.
assertThat(vm.state.value?.form?.end).isEqualTo(LocalDateTime(2030, 1, 15, 10, 0))
assertThat(vm.state.value?.form?.end?.dateTime).isEqualTo(LocalDateTime(2030, 1, 15, 10, 0))
job.cancel()
}
@@ -407,4 +412,44 @@ class EventEditViewModelTest {
assertThat(capLength(new, "")).isEqualTo("a".repeat(254))
}
@Test
fun `a drafted block opens at its real length across the fall-back hour`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { calendarsResult = listOf(cal(1L)) }
val vm = viewModel(tempDir, fake).apply { deviceZone = { berlin } }
val job = activate(vm)
// An hour from 02:30 CEST ends at 02:30 CET, the same wall clock.
vm.openNew(LocalDate(2026, 10, 25), startMinutes = 2 * 60 + 30, durationMinutes = 60)
advanceUntilIdle()
val form = vm.state.value!!.form!!
assertThat(form.startInstant(berlin)).isEqualTo(Instant.parse("2026-10-25T00:30:00Z"))
assertThat(form.endInstant(berlin)).isEqualTo(Instant.parse("2026-10-25T01:30:00Z"))
assertThat(form.problems(berlin)).isEmpty()
job.cancel()
}
@Test
fun `a drafted block in the spring-forward gap keeps its length`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { calendarsResult = listOf(cal(1L)) }
val vm = viewModel(tempDir, fake).apply { deviceZone = { berlin } }
val job = activate(vm)
// 02:30 doesn't exist on 29 March: the start moves on to 03:30, the end with it.
vm.openNew(LocalDate(2026, 3, 29), startMinutes = 2 * 60 + 30, durationMinutes = 30)
advanceUntilIdle()
val form = vm.state.value!!.form!!
assertThat(form.start.dateTime).isEqualTo(LocalDateTime(2026, 3, 29, 3, 30))
assertThat(form.end.dateTime).isEqualTo(LocalDateTime(2026, 3, 29, 4, 0))
assertThat(form.problems(berlin)).isEmpty()
job.cancel()
}
private val berlin = TimeZone.of("Europe/Berlin")
}