diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt index 4b4d1f94..b19c23c6 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt @@ -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) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesSyncEngine.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesSyncEngine.kt index 962f4f6f..d9cfded4 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesSyncEngine.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesSyncEngine.kt @@ -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, 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 2a4b75a9..fca1ba65 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt @@ -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 = 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(), 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 919fa4e1..61c843df 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventShift.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventShift.kt @@ -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) } 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 8b407f81..2bf1c249 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/InsertEventForm.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/InsertEventForm.kt @@ -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:" 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 55603650..e8ab23b8 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,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(), 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 0298958f..4fba20f1 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 @@ -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, 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 be843e37..9a01ec9a 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 @@ -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(null) private val _saveState = MutableStateFlow(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) } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt index 076d7167..25302a7b 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt @@ -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") diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt index 7a68ff47..bf6aa944 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt @@ -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) } 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 a3f78f8f..8001b5a4 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt @@ -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")) 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 fb8b8f31..438387e7 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventShiftTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventShiftTest.kt @@ -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) } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/InsertEventFormTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/InsertEventFormTest.kt index aaf9b60b..86afaeb6 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/InsertEventFormTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/InsertEventFormTest.kt @@ -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 diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/ParsedIcsFormTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/ParsedIcsFormTest.kt index 0041eadb..d7e4e8f4 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/ParsedIcsFormTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/ParsedIcsFormTest.kt @@ -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") } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModelTest.kt index 8ee8926f..4e35d6ce 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModelTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModelTest.kt @@ -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") }