From e2b35b3fac660c8fb9ef56710efa179bc4cbf976 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 12 Oct 2026 23:24:23 +0200 Subject: [PATCH] Keep timeline events in the right repeated hour on DST fall-back day (#387) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On the day the clocks go back, 02:00–03:00 happens twice. Event times now remember which of the two they mean, so moving, resizing, editing and saving keep them in the right one. - The week and day timeline place blocks by clock time, so events after a DST change no longer sit an hour off. - An event ending in the repeated hour is drawn up to the end of that hour, and its label shows its real end time. - Drags, drops and resizes keep the event's real length across a clock change; a resize only changes the edge being dragged. - A drop onto a repeated time lands on the first one, unless the event is already in the second one. - The editor, event details and the "local time" line add the zone abbreviation to a repeated time so the two can be told apart. - Saving checks and writes the real start and end times, so a span ending in the repeated hour is valid and switching between the two is saved. Closes #387 --- .../data/calendar/EventWriteMapper.kt | 12 +- .../calendula/domain/EventForm.kt | 59 ++++++--- .../calendula/domain/EventShift.kt | 59 +++++++-- .../calendula/domain/InsertEventForm.kt | 28 ++-- .../calendula/domain/TimeZoneCatalog.kt | 16 ++- .../calendula/domain/ics/ParsedIcsForm.kt | 3 + .../ui/common/RescheduleViewModel.kt | 10 +- .../calendula/ui/common/TimeFormat.kt | 18 +++ .../calendula/ui/common/TimelineDrag.kt | 101 ++++++++++++--- .../calendula/ui/detail/EventDetailScreen.kt | 10 +- .../calendula/ui/edit/EventEditScreen.kt | 54 ++++---- .../calendula/ui/edit/EventEditViewModel.kt | 58 ++++++--- .../calendula/ui/week/WeekScreen.kt | 27 +++- .../calendula/ui/week/WeekViewModel.kt | 66 +++++++++- .../data/calendar/EventWriteMapperTest.kt | 35 +++++ .../calendula/domain/EventFormTest.kt | 120 +++++++++--------- .../calendula/domain/EventShiftTest.kt | 39 +++++- .../ui/common/RepeatedHourZoneTest.kt | 30 +++++ .../calendula/ui/common/TimelineDropTest.kt | 87 +++++++++++++ .../calendula/ui/week/WeekLayoutTest.kt | 103 +++++++++++++++ 20 files changed, 733 insertions(+), 202 deletions(-) create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RepeatedHourZoneTest.kt 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 34c36b29..4b4d1f94 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,5 +1,7 @@ package de.jeanlucmakiola.calendula.data.calendar +import de.jeanlucmakiola.calendula.domain.resolveIn +import kotlinx.datetime.toKotlinTimeZone import android.provider.CalendarContract import de.jeanlucmakiola.calendula.domain.AccessLevel import de.jeanlucmakiola.calendula.domain.Availability @@ -48,8 +50,8 @@ internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDa } else { val writeZone = writeZone(zone) EventWriteTimes( - dtStartMillis = start.toJavaLocalDateTime().atZone(writeZone).toInstant().toEpochMilli(), - dtEndMillis = end.toJavaLocalDateTime().atZone(writeZone).toInstant().toEpochMilli(), + dtStartMillis = start.resolveIn(writeZone.toKotlinTimeZone(), startOffset).toEpochMilliseconds(), + dtEndMillis = end.resolveIn(writeZone.toKotlinTimeZone(), endOffset).toEpochMilliseconds(), timezone = writeZone.id, ) } @@ -181,12 +183,14 @@ 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. + // 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). val timesChanged = updated.start != original.start || updated.end != original.end || updated.isAllDay != original.isAllDay || updated.rrule != original.rrule || - updated.timezone != original.timezone + updated.timezone != original.timezone || + updated.toWriteTimes(zone) != original.toWriteTimes(zone) if (!timesChanged) return@buildMap val newTimes = updated.toWriteTimes(zone) 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 5617c206..ac2bec3c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt @@ -4,7 +4,8 @@ import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalTime import kotlinx.datetime.TimeZone -import kotlinx.datetime.toInstant +import kotlinx.datetime.UtcOffset +import kotlinx.datetime.offsetAt import kotlinx.datetime.toLocalDateTime import kotlin.time.Instant @@ -36,6 +37,13 @@ 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, when they came from real + * instants. Only a wall-clock time the fall-back hour repeats needs them: + * both of its instants share one [LocalDateTime] (#387). + */ + val startOffset: UtcOffset? = null, + val endOffset: UtcOffset? = null, val location: String = "", val description: String = "", /** Reminder lead times in minutes before the start, deduplicated. */ @@ -145,16 +153,15 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone): ?.takeIf { runCatching { TimeZone.of(it) }.isSuccess } } val formZone = pinnedZone?.let { TimeZone.of(it) } ?: zone + val begin = Instant.fromEpochMilliseconds(beginMillis) + val finish = Instant.fromEpochMilliseconds(endMillis) val (start, end) = if (instance.isAllDay) { - val startDate = Instant.fromEpochMilliseconds(beginMillis) - .toLocalDateTime(TimeZone.UTC).date - val endExclusive = Instant.fromEpochMilliseconds(endMillis) - .toLocalDateTime(TimeZone.UTC).date + 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)) } else { - Instant.fromEpochMilliseconds(beginMillis).toLocalDateTime(formZone) to - Instant.fromEpochMilliseconds(endMillis).toLocalDateTime(formZone) + begin.toLocalDateTime(formZone) to finish.toLocalDateTime(formZone) } return EventForm( calendarId = instance.calendarId, @@ -163,6 +170,8 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone): start = start, end = end, timezone = pinnedZone, + startOffset = if (instance.isAllDay) null else formZone.offsetAt(begin), + endOffset = if (instance.isAllDay) null else formZone.offsetAt(finish), location = instance.location.orEmpty(), description = description.orEmpty(), reminders = reminders.map { it.minutes }.distinct().sorted(), @@ -221,20 +230,18 @@ fun EventDetail.toEditSnapshot(beginMillis: Long, endMillis: Long, zone: TimeZon ) /** - * The form's times as they land in [target] — what a pinned event's wall-clock - * actually means where the user is standing. Null when there's nothing to - * disambiguate: an unpinned event (already in [target]), one pinned to [target] - * itself, an all-day event (no zone), or an unparseable pinned zone. + * Whether this form's times mean something else in [target] — a timed event + * pinned to another zone. False when there's nothing to disambiguate: an + * unpinned event (already in [target]), one pinned to [target] itself, an + * all-day event (no zone), or an unparseable pinned zone. * - * The form edits a pinned event in its own zone, so this is what lets the UI - * show the other side of the pair rather than making the user do the arithmetic. + * The form edits a pinned event in its own zone, so this is what tells the UI + * to show the other side of the pair rather than making the user do the arithmetic. */ -fun EventForm.timesIn(target: TimeZone): Pair? { - if (isAllDay) return null - val pinned = timezone?.let { runCatching { TimeZone.of(it) }.getOrNull() } ?: return null - if (pinned.id == target.id) return null - return start.toInstant(pinned).toLocalDateTime(target) to - end.toInstant(pinned).toLocalDateTime(target) +fun EventForm.isPinnedAwayFrom(target: TimeZone): Boolean { + if (isAllDay) return false + val pinned = timezone?.let { runCatching { TimeZone.of(it) }.getOrNull() } ?: return false + return pinned.id != target.id } /** @@ -254,9 +261,19 @@ fun EventForm.populatedFields(): Set = buildSet { if (attendees.isNotEmpty()) add(EventFormField.Attendees) } -fun EventForm.problems(): Set = buildSet { +/** + * What stops this form from saving. A timed span is checked as instants in the + * form's zone (else [deviceZone]): one ending in the fall-back hour's repeat can + * end on an earlier wall clock than it starts (#387). + */ +fun EventForm.problems(deviceZone: TimeZone): Set = buildSet { if (calendarId == null) add(EventFormProblem.NoCalendar) - val endsTooEarly = if (isAllDay) end.date < start.date else end < start + val endsTooEarly = if (isAllDay) { + end.date < start.date + } else { + val zone = resolvedZone(deviceZone) + endInstant(zone) < startInstant(zone) + } if (endsTooEarly) add(EventFormProblem.EndBeforeStart) // An UNTIL before the first day would make the provider generate zero // occurrences — the event would silently vanish from every view. 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 8ec429a7..bb0db96d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventShift.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventShift.kt @@ -3,10 +3,14 @@ package de.jeanlucmakiola.calendula.domain import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.LocalDateTime import kotlinx.datetime.TimeZone +import kotlinx.datetime.UtcOffset +import kotlinx.datetime.offsetAt import kotlinx.datetime.plus import kotlinx.datetime.toInstant +import kotlinx.datetime.toJavaZoneId import kotlinx.datetime.toLocalDateTime import kotlin.time.Instant +import kotlin.time.toJavaInstant /** * The zone this form's wall-clock times mean, matching what the data layer @@ -15,6 +19,43 @@ import kotlin.time.Instant fun EventForm.resolvedZone(deviceZone: TimeZone): TimeZone = timezone?.let { runCatching { TimeZone.of(it) }.getOrNull() } ?: deviceZone +/** + * This wall-clock time in [zone], at [preferred] when the fall-back hour repeats + * it and that is one of its two offsets — otherwise the earlier one (#387). + */ +fun LocalDateTime.resolveIn(zone: TimeZone, preferred: UtcOffset?): Instant { + val kept = preferred?.let { toInstant(it) } + return kept?.takeIf { it.toLocalDateTime(zone) == this } ?: toInstant(zone) +} + +/** Whether this instant's wall clock in [zone] is one the fall-back hour repeats. */ +fun Instant.inRepeatedHour(zone: TimeZone): Boolean { + val zid = zone.toJavaZoneId() + return zid.rules.getValidOffsets(java.time.LocalDateTime.ofInstant(toJavaInstant(), zid)).size > 1 +} + +/** The form's start as an instant in [zone], keeping its [EventForm.startOffset]. */ +fun EventForm.startInstant(zone: TimeZone): Instant = start.resolveIn(zone, startOffset) + +/** The form's end as an instant in [zone], keeping its [EventForm.endOffset]. */ +fun EventForm.endInstant(zone: TimeZone): Instant = end.resolveIn(zone, endOffset) + +/** 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.offsetAt(start), + endOffset = zone.offsetAt(end), +) + +/** + * 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. + */ +fun EventForm.withEnd(end: LocalDateTime): EventForm = + if (end == this.end) this else copy(end = end, endOffset = null) + /** * The form moved so it starts at [newStart], keeping its **instant** duration — * a recurring event's length travels to the provider as `DURATION`, so keeping @@ -26,11 +67,8 @@ fun EventForm.resolvedZone(deviceZone: TimeZone): TimeZone = fun EventForm.shiftedTo(newStart: Instant, deviceZone: TimeZone): EventForm { if (isAllDay) return this val zone = resolvedZone(deviceZone) - val span = end.toInstant(zone) - start.toInstant(zone) - return copy( - start = newStart.toLocalDateTime(zone), - end = (newStart + span).toLocalDateTime(zone), - ) + val span = endInstant(zone) - startInstant(zone) + return withTimes(newStart, newStart + span, zone) } /** @@ -48,16 +86,13 @@ fun EventForm.shiftedByDays(days: Int, deviceZone: TimeZone): EventForm { ) } val zone = resolvedZone(deviceZone) - val span = end.toInstant(zone) - start.toInstant(zone) - return copy(start = newStart, end = (newStart.toInstant(zone) + span).toLocalDateTime(zone)) + val span = endInstant(zone) - startInstant(zone) + val startAt = newStart.resolveIn(zone, startOffset) + return withTimes(startAt, startAt + span, zone) } /** The form with new start and end, each in the form's own zone — a resize. All-day is untouched. */ fun EventForm.resizedTo(newStart: Instant, newEnd: Instant, deviceZone: TimeZone): EventForm { if (isAllDay) return this - val zone = resolvedZone(deviceZone) - return copy( - start = newStart.toLocalDateTime(zone), - end = newEnd.toLocalDateTime(zone), - ) + return withTimes(newStart, newEnd, resolvedZone(deviceZone)) } 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 2dd0db8d..e6a40c89 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/InsertEventForm.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/InsertEventForm.kt @@ -4,7 +4,7 @@ import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalTime import kotlinx.datetime.TimeZone -import kotlinx.datetime.toInstant +import kotlinx.datetime.offsetAt import kotlinx.datetime.toLocalDateTime import kotlin.time.Duration.Companion.hours import kotlin.time.Instant @@ -35,7 +35,15 @@ fun buildInsertEventForm( zone: TimeZone, now: Instant, ): EventForm { - val (start, end) = if (isAllDay) { + 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) { // 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). @@ -48,14 +56,7 @@ fun buildInsertEventForm( ?: startDate LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0)) } else { - val startTime = beginMillis - ?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(zone) } - ?: nextFullHour(now, zone) - val endTime = endMillis - ?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(zone) } - ?.takeIf { it >= startTime } - ?: (startTime.toInstant(zone) + 1.hours).toLocalDateTime(zone) - startTime to endTime + timed.first.toLocalDateTime(zone) to timed.second.toLocalDateTime(zone) } return EventForm( calendarId = null, @@ -63,6 +64,8 @@ fun buildInsertEventForm( isAllDay = isAllDay, start = start, end = end, + startOffset = timed?.let { zone.offsetAt(it.first) }, + endOffset = timed?.let { zone.offsetAt(it.second) }, location = location.orEmpty(), description = description.orEmpty(), // Bare RRULE value (Events.RRULE convention); tolerate a leading "RRULE:" @@ -71,8 +74,7 @@ fun buildInsertEventForm( ) } -private fun nextFullHour(now: Instant, zone: TimeZone): LocalDateTime { +private fun nextFullHour(now: Instant): Instant { val hourMillis = 3_600_000L - val rounded = (now.toEpochMilliseconds() / hourMillis + 1) * hourMillis - return Instant.fromEpochMilliseconds(rounded).toLocalDateTime(zone) + return Instant.fromEpochMilliseconds((now.toEpochMilliseconds() / hourMillis + 1) * hourMillis) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalog.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalog.kt index d19e483d..49ed06c6 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalog.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalog.kt @@ -157,12 +157,18 @@ fun timeZoneOptionOf( * the offset isn't stated twice in ICU's spelling and ours. */ fun zoneDescriptor(option: TimeZoneOption): String { + val name = zoneAbbreviation(option) + val statesOffset = option.shortName == "UTC" || option.shortName.startsWith("GMT") + return if (statesOffset) name else "$name · ${formatGmtOffset(option.offsetMinutes)}" +} + +/** + * [option]'s short name on its own — "CEST", "UTC" — with an offset-shaped one + * normalised to our GMT form, as [zoneDescriptor] spells it. + */ +fun zoneAbbreviation(option: TimeZoneOption): String { val abbrev = option.shortName - return when { - abbrev == "UTC" -> "UTC" - abbrev.startsWith("GMT") -> formatGmtOffset(option.offsetMinutes) - else -> "$abbrev · ${formatGmtOffset(option.offsetMinutes)}" - } + return if (abbrev.startsWith("GMT")) formatGmtOffset(option.offsetMinutes) else abbrev } /** 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 d2609a9f..4263c785 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 @@ -5,6 +5,7 @@ import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalTime import kotlinx.datetime.TimeZone +import kotlinx.datetime.offsetAt import kotlinx.datetime.toLocalDateTime /** @@ -34,6 +35,8 @@ fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm { isAllDay = isAllDay, start = start, end = end, + startOffset = if (isAllDay) null else zone.offsetAt(this.start), + endOffset = if (isAllDay) null else zone.offsetAt(this.end), location = location.orEmpty(), description = description.orEmpty(), reminders = semanticReminderMinutes(), diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt index d30a5e8b..13868b09 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt @@ -16,6 +16,8 @@ import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence import de.jeanlucmakiola.calendula.domain.problems import de.jeanlucmakiola.calendula.domain.realignRecurrence import de.jeanlucmakiola.calendula.domain.resizedTo +import de.jeanlucmakiola.calendula.domain.endInstant +import de.jeanlucmakiola.calendula.domain.startInstant import de.jeanlucmakiola.calendula.domain.resolvedZone import de.jeanlucmakiola.calendula.domain.shiftedByDays import de.jeanlucmakiola.calendula.domain.shiftedTo @@ -329,7 +331,7 @@ class RescheduleViewModel @Inject constructor( // The UNTIL check is deferred to the write, which knows how far the move // reaches and so which date has to clear it. - if ((shifted.problems() - EventFormProblem.RecurrenceEndsBeforeStart).isNotEmpty()) { + if ((shifted.problems(zone) - EventFormProblem.RecurrenceEndsBeforeStart).isNotEmpty()) { _outcome.value = MoveOutcome.Failed return null } @@ -495,8 +497,7 @@ class RescheduleViewModel @Inject constructor( private fun resizedEndMillis(prepared: PreparedMove): Long? { if (prepared.request.target !is MoveTarget.Span) return null val form = prepared.updated - return form.end.toInstant(form.resolvedZone(TimeZone.currentSystemDefault())) - .toEpochMilliseconds() + return form.endInstant(form.resolvedZone(TimeZone.currentSystemDefault())).toEpochMilliseconds() } /** @@ -508,8 +509,7 @@ class RescheduleViewModel @Inject constructor( return if (form.isAllDay) { form.start.date.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds() } else { - form.start.toInstant(form.resolvedZone(TimeZone.currentSystemDefault())) - .toEpochMilliseconds() + form.startInstant(form.resolvedZone(TimeZone.currentSystemDefault())).toEpochMilliseconds() } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeFormat.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeFormat.kt index fb6262a3..e2b43ab9 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeFormat.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeFormat.kt @@ -1,7 +1,13 @@ package de.jeanlucmakiola.calendula.ui.common import androidx.compose.runtime.staticCompositionLocalOf +import de.jeanlucmakiola.calendula.domain.inRepeatedHour +import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf +import de.jeanlucmakiola.calendula.domain.zoneAbbreviation +import kotlinx.datetime.TimeZone import java.time.LocalTime +import kotlin.time.Instant +import kotlin.time.toJavaInstant import java.time.format.DateTimeFormatter import java.util.Locale @@ -68,3 +74,15 @@ fun formatHourLabel(hour: Int, is24Hour: Boolean, locale: Locale): String = } private const val MINUTES_PER_DAY = 1_440 + +/** + * [formatted] — [instant]'s time in [zone] — with the zone's abbreviation after + * it ("2:30 AM CEST") when the fall-back hour repeats that time, the one case + * where the time alone doesn't say which instant it is (#387). + */ +fun withRepeatedHourZone(formatted: String, instant: Instant, zone: TimeZone, locale: Locale): String { + if (!instant.inRepeatedHour(zone)) return formatted + val option = timeZoneOptionOf(zone.id, locale, instant.toJavaInstant(), ::icuTimeZoneRegion) + ?: return formatted + return "$formatted ${zoneAbbreviation(option)}" +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt index b93f87ef..e924a8af 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt @@ -45,8 +45,12 @@ import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.domain.inRepeatedHour +import de.jeanlucmakiola.calendula.domain.resolveIn import de.jeanlucmakiola.calendula.ui.week.MINUTES_PER_DAY import de.jeanlucmakiola.calendula.ui.week.TimedBlock +import de.jeanlucmakiola.calendula.ui.week.minutesPast +import de.jeanlucmakiola.calendula.ui.week.wallClockEndMinutes import de.jeanlucmakiola.floret.identity.rememberReduceMotion import de.jeanlucmakiola.floret.locale.currentLocale import kotlinx.coroutines.delay @@ -54,6 +58,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.LocalDate import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.offsetAt import kotlinx.datetime.plus import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalTime @@ -61,6 +66,7 @@ import kotlinx.datetime.TimeZone import kotlinx.datetime.toInstant import kotlinx.datetime.toLocalDateTime import kotlin.math.roundToInt +import kotlin.time.Duration import kotlin.time.Instant /** Start granularity a dragged block snaps to, matching the big calendar apps. */ @@ -506,6 +512,8 @@ class TimelineDragController { * finger is on; this is what turns where it lands back into an event start. */ private var clipOffsetMin = 0 + private var dragZone: TimeZone? = null + private var dragDuration = Duration.ZERO /** What the picked-up block drew its title over — see [TimelineDrag.titleLines]. */ private var titleLines = 0 @@ -539,6 +547,7 @@ class TimelineDragController { fun begin( block: TimedBlock, clipOffsetMin: Int, + zone: TimeZone, titleLines: Int, pointerInRoot: Offset, blockInRoot: Offset, @@ -547,6 +556,8 @@ class TimelineDragController { if (!isSelected(block)) deselect() source = block this.clipOffsetMin = clipOffsetMin + dragZone = zone + dragDuration = (block.event.end - block.event.start).coerceAtLeast(Duration.ZERO) this.titleLines = titleLines settling = null isDragging = true @@ -573,6 +584,8 @@ class TimelineDragController { resizeSlot = null source = null clipOffsetMin = 0 + dragZone = null + dragDuration = Duration.ZERO titleLines = 0 isDragging = false pointerMoved = false @@ -664,17 +677,12 @@ class TimelineDragController { pointerInRoot: Offset, ) { val start = block.event.start.toLocalDateTime(zone) - val end = block.event.end.toLocalDateTime(zone) source = block resizeEdge = edge resizeDay = start.date resizeStartMin = start.time.toSecondOfDay() / 60 // Wall clock at both ends, as the drop resolves them (DST-safe). - resizeEndMin = maxOf( - resizeStartMin, - (end.date.toEpochDays() - start.date.toEpochDays()).toInt() * MINUTES_PER_DAY + - end.time.toSecondOfDay() / 60, - ) + resizeEndMin = wallClockEndMinutes(resizeStartMin, block.event.start, block.event.end, start.date, zone) clipOffsetMin = 0 this.titleLines = titleLines settling = null @@ -701,6 +709,7 @@ class TimelineDragController { fun finishResize(zone: TimeZone): TimelineResizeDrop? { val landed = drag val origin = originSlot + val edge = resizeEdge cancel() if (landed == null || landed.slot == origin) return null settlingSelected = selection?.event?.instanceId == landed.event.instanceId @@ -708,10 +717,26 @@ class TimelineDragController { liftedInstanceId = landed.event.instanceId settledOrigin = landed.event.eventId to landed.event.start settledAbandonedMark = abandonedTicks?.value ?: 0 + // Only the held edge moves. The other keeps its own instant rather than + // a round trip through the grid, where an end in the fall-back hour's + // repeat is drawn to that hour's end, not at its own time (#387). val drop = TimelineResizeDrop( event = landed.event, - start = wallClockInstant(landed.date, landed.eventStartMin, zone), - end = wallClockInstant(landed.date, landed.eventStartMin + landed.eventSpanMin, zone), + start = if (edge == ResizeEdge.Top) { + wallClockInstant(landed.date, landed.eventStartMin, zone, keepOffsetOf = landed.event.start) + } else { + landed.event.start + }, + end = if (edge == ResizeEdge.Bottom) { + wallClockInstant( + landed.date, + landed.eventStartMin + landed.eventSpanMin, + zone, + keepOffsetOf = landed.event.end, + ) + } else { + landed.event.end + }, ) settledEnd = drop.end return drop @@ -835,13 +860,22 @@ class TimelineDragController { val origin = grid.positionInRoot() val rawMinutes = (pointer.y - grab.y - origin.y) / hourPx * 60f val snapped = (rawMinutes / DRAG_SNAP_MINUTES).roundToInt() * DRAG_SNAP_MINUTES - val eventSpan = (block.event.end - block.event.start).inWholeMinutes.toInt() - .coerceAtLeast(0) - val floor = dragFloorMin(clipOffsetMin, eventSpan) - val startMin = snapped.coerceIn(floor, MINUTES_PER_DAY - DRAG_SNAP_MINUTES) - val eventStartMin = startMin - clipOffsetMin + val zone = dragZone ?: return val column = ((pointer.x - origin.x) / columnPx).toInt().coerceIn(0, days.lastIndex) val dayIndex = if (geometry.isRtl) days.lastIndex - column else column + val target = days[dayIndex] + val keep = block.event.start + // Measured where it would land: the drop keeps the real duration, which in + // wall clock gains or loses the hour of a DST change it crosses (#387). + val floor = dragFloorMin( + clipOffsetMin, + wallClockSpanMinutes(target, snapped - clipOffsetMin, dragDuration, zone, keep), + ) + val held = snapped.coerceIn(floor, MINUTES_PER_DAY - DRAG_SNAP_MINUTES) - clipOffsetMin + // A start in the spring-forward gap lands an hour on; show it there. + val eventStartMin = wallClockInstant(target, held, zone, keep).minutesPast(target, zone) + val startMin = (eventStartMin + clipOffsetMin).coerceAtMost(MINUTES_PER_DAY - DRAG_SNAP_MINUTES) + val eventSpan = wallClockSpanMinutes(target, eventStartMin, dragDuration, zone, keep) val visible = -dayIndex..days.lastIndex - dayIndex val onGrid = dragSlices(eventStartMin, eventSpan, within = visible) val slices = onGrid.ifEmpty { @@ -929,10 +963,7 @@ fun rememberTimelineDragController(): TimelineDragController { * so the offset has to come back off at the drop (#253). */ fun TimedBlock.clipOffsetMinutes(day: LocalDate, zone: TimeZone): Int { - val start = event.start.toLocalDateTime(zone) - val days = (day.toEpochDays() - start.date.toEpochDays()).toInt() - val minutes = days * MINUTES_PER_DAY + startMin - start.time.toSecondOfDay() / 60 - return minutes.coerceAtLeast(0) + return (startMin - event.start.minutesPast(day, zone)).coerceAtLeast(0) } /** @@ -954,13 +985,41 @@ fun TimedBlock.continuesAfter(day: LocalDate, zone: TimeZone): Boolean = * visibly settles there. */ fun TimelineDrop.startInstant(zone: TimeZone): Instant = - wallClockInstant(date, startMin - clipOffsetMin, zone) + wallClockInstant(date, startMin - clipOffsetMin, zone, keepOffsetOf = event.start) -/** [minutes] past [date]'s midnight, either way past it, as wall clock in [zone] (DST-safe). */ -fun wallClockInstant(date: LocalDate, minutes: Int, zone: TimeZone): Instant { +/** + * The wall-clock minutes an event lasting [duration] covers when it starts + * [startMin] past [date]'s midnight in [zone] — its real length, give or take a + * DST change in between (#387). + */ +internal fun wallClockSpanMinutes( + date: LocalDate, + startMin: Int, + duration: Duration, + zone: TimeZone, + keepOffsetOf: Instant? = null, +): Int { + val start = wallClockInstant(date, startMin, zone, keepOffsetOf) + return wallClockEndMinutes(startMin, start, start + duration, date, zone) - startMin +} + +/** + * [minutes] past [date]'s midnight, either way past it, as wall clock in [zone] + * (DST-safe). A time the fall-back hour repeats lands on its earlier pass, unless + * [keepOffsetOf] is itself in that hour's later one: an event there stays on it + * (#387). An instant outside the hour says nothing about which pass the grid + * slot means, so a drop never depends on the season the event came from. + */ +fun wallClockInstant( + date: LocalDate, + minutes: Int, + zone: TimeZone, + keepOffsetOf: Instant? = null, +): Instant { val day = date.plus(minutes.floorDiv(MINUTES_PER_DAY), DateTimeUnit.DAY) val minute = minutes.mod(MINUTES_PER_DAY) - return LocalDateTime(day, LocalTime(minute / 60, minute % 60)).toInstant(zone) + val kept = keepOffsetOf?.takeIf { it.inRepeatedHour(zone) }?.let(zone::offsetAt) + return LocalDateTime(day, LocalTime(minute / 60, minute % 60)).resolveIn(zone, kept) } /** diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt index 7e94e35e..345a981a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt @@ -102,6 +102,7 @@ import de.jeanlucmakiola.calendula.ui.common.eventFill import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter +import de.jeanlucmakiola.calendula.ui.common.withRepeatedHourZone import de.jeanlucmakiola.calendula.ui.common.recurrenceText import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel import kotlinx.coroutines.launch @@ -807,12 +808,13 @@ private fun formatWhen( } val endLdt = instance.end.toJavaLocalDateTime(zid) + val startTime = withRepeatedHourZone(timeShort.format(startLdt), instance.start, zone, locale) + val endTime = withRepeatedHourZone(timeShort.format(endLdt), instance.end, zone, locale) return if (startLdt.toLocalDate() == endLdt.toLocalDate()) { - dateFull.format(startLdt.toLocalDate()) to - "${timeShort.format(startLdt)} – ${timeShort.format(endLdt)}" + dateFull.format(startLdt.toLocalDate()) to "$startTime – $endTime" } else { - val start = "${dateMedium.format(startLdt.toLocalDate())} ${timeShort.format(startLdt)}" - val end = "${dateMedium.format(endLdt.toLocalDate())} ${timeShort.format(endLdt)}" + val start = "${dateMedium.format(startLdt.toLocalDate())} $startTime" + val end = "${dateMedium.format(endLdt.toLocalDate())} $endTime" "$start – $end" to null } } 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 90dff7a1..89bfb6ce 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 @@ -116,10 +116,13 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EventAttendee import de.jeanlucmakiola.calendula.domain.EventColorOption import de.jeanlucmakiola.calendula.domain.EventForm +import de.jeanlucmakiola.calendula.domain.startInstant +import de.jeanlucmakiola.calendula.domain.endInstant +import de.jeanlucmakiola.calendula.domain.resolvedZone +import de.jeanlucmakiola.calendula.domain.isPinnedAwayFrom import de.jeanlucmakiola.calendula.domain.EventFormField import de.jeanlucmakiola.calendula.domain.zoneDescriptor import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf -import de.jeanlucmakiola.calendula.domain.timesIn import de.jeanlucmakiola.calendula.domain.EventFormProblem import de.jeanlucmakiola.calendula.domain.RecurrenceEnd import de.jeanlucmakiola.calendula.domain.RecurrenceFreq @@ -162,6 +165,7 @@ import de.jeanlucmakiola.floret.reminders.ReminderUnit import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter +import de.jeanlucmakiola.calendula.ui.common.withRepeatedHourZone import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel import de.jeanlucmakiola.calendula.ui.common.reminderUnitLabel import de.jeanlucmakiola.calendula.ui.common.nextOccurrencesText @@ -174,7 +178,6 @@ import kotlinx.datetime.TimeZone import kotlinx.datetime.isoDayNumber import kotlinx.datetime.toJavaDayOfWeek import kotlinx.datetime.toJavaLocalDate -import kotlinx.datetime.toInstant import kotlinx.datetime.toJavaLocalTime import kotlinx.datetime.toLocalDateTime import kotlin.time.toJavaInstant @@ -184,6 +187,7 @@ import java.time.format.FormatStyle import java.time.format.TextStyle as JavaTextStyle import java.util.Locale import kotlin.time.Clock +import kotlin.time.Instant /** * Full-screen event form: create (v1.2, from the FAB's anchor date) and edit @@ -662,9 +666,14 @@ 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) } ScheduleRow( label = stringResource(R.string.event_edit_starts), dateTime = form.start, + instant = startAt, + zone = formZone, isAllDay = form.isAllDay, locale = locale, onDateClick = { picker = PickerTarget.StartDate }, @@ -674,6 +683,8 @@ private fun EventEditContent( ScheduleRow( label = stringResource(R.string.event_edit_ends), dateTime = form.end, + instant = endAt, + zone = formZone, isAllDay = form.isAllDay, locale = locale, onDateClick = { picker = PickerTarget.EndDate }, @@ -692,20 +703,14 @@ private fun EventEditContent( // The rows above edit a pinned event in *its own* zone, which is the // time it was set at — but that says nothing about when it lands for // whoever is reading it. Spell the local equivalent out rather than - // leaving the user to do the offset arithmetic. Absent (null) unless - // the event is pinned somewhere other than here. - // Keyed rather than recomputed: this sits in the same Column as the - // title field, so it would otherwise re-parse the zone on every - // keystroke. - val localTimes = remember(form.timezone, form.start, form.end, form.isAllDay) { - form.timesIn(TimeZone.currentSystemDefault()) - } - localTimes?.let { (localStart, localEnd) -> + // leaving the user to do the offset arithmetic. Absent unless the + // event is pinned somewhere other than here. + if (form.isPinnedAwayFrom(TimeZone.currentSystemDefault())) { Spacer(Modifier.height(2.dp)) Text( text = stringResource( R.string.event_edit_timezone_local_time, - formatTimeRange(localStart, localEnd, locale), + formatTimeRange(startAt, endAt, TimeZone.currentSystemDefault(), locale), ), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -769,7 +774,7 @@ private fun EventEditContent( timeZoneOptionOf( id, locale, - at = form.start.toInstant(zone).toJavaInstant(), + at = form.startInstant(zone).toJavaInstant(), regionOf = ::icuTimeZoneRegion, ) } @@ -2251,18 +2256,21 @@ private fun EditCard( } /** - * "2:00 PM – 3:00 PM", honouring the user's 12/24-hour setting. Collapses to one - * time when both land on the same minute (an instant event shouldn't read as a - * range against itself). Dates are deliberately absent — the rows above carry - * them. + * "2:00 PM – 3:00 PM" in [zone], honouring the user's 12/24-hour setting. Collapses + * to one time for an instant event, which shouldn't read as a range against + * itself. Dates are deliberately absent — the rows above carry them. */ @Composable -private fun formatTimeRange(start: LocalDateTime, end: LocalDateTime, locale: Locale): String { +private fun formatTimeRange(start: Instant, end: Instant, zone: TimeZone, locale: Locale): String { val use24Hour = LocalUse24HourFormat.current val timeFormat = remember(locale, use24Hour) { timeOfDayFormatter(use24Hour, locale) } - val from = timeFormat.format(start.time.toJavaLocalTime()) - val to = timeFormat.format(end.time.toJavaLocalTime()) - return if (from == to) from else "$from – $to" + fun format(at: Instant) = withRepeatedHourZone( + timeFormat.format(at.toLocalDateTime(zone).time.toJavaLocalTime()), + at, + zone, + locale, + ) + return if (start == end) format(start) else "${format(start)} – ${format(end)}" } /** @@ -2344,6 +2352,8 @@ private fun firstLineHeight( private fun ScheduleRow( label: String, dateTime: LocalDateTime, + instant: Instant, + zone: TimeZone, isAllDay: Boolean, locale: Locale, onDateClick: () -> Unit, @@ -2386,7 +2396,7 @@ private fun ScheduleRow( ) if (!isAllDay) { Text( - text = timeFormat.format(dateTime.time.toJavaLocalTime()), + text = withRepeatedHourZone(timeFormat.format(dateTime.time.toJavaLocalTime()), instant, zone, locale), style = MaterialTheme.typography.titleMedium, color = valueColor, modifier = Modifier diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt index 99894e80..c1a50a9e 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 @@ -19,6 +19,12 @@ import de.jeanlucmakiola.calendula.domain.EditSnapshot import de.jeanlucmakiola.calendula.domain.EventAttendee import de.jeanlucmakiola.calendula.domain.EventColorOption import de.jeanlucmakiola.calendula.domain.EventForm +import de.jeanlucmakiola.calendula.domain.endInstant +import de.jeanlucmakiola.calendula.domain.resolveIn +import de.jeanlucmakiola.calendula.domain.resolvedZone +import de.jeanlucmakiola.calendula.domain.startInstant +import de.jeanlucmakiola.calendula.domain.withEnd +import de.jeanlucmakiola.calendula.domain.withTimes import de.jeanlucmakiola.calendula.domain.EventFormField import de.jeanlucmakiola.calendula.domain.RecurringWriteScope import de.jeanlucmakiola.calendula.domain.isEventTarget @@ -49,6 +55,7 @@ import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalTime import kotlinx.datetime.TimeZone +import kotlinx.datetime.offsetAt import kotlinx.datetime.toInstant import kotlinx.datetime.toLocalDateTime import kotlin.coroutines.cancellation.CancellationException @@ -289,7 +296,11 @@ class EventEditViewModel @Inject constructor( EventEditUiState( form = resolved, calendars = pickerCalendars, - problems = if (local.showProblems) resolved.problems() else emptySet(), + problems = if (local.showProblems) { + resolved.problems(TimeZone.currentSystemDefault()) + } else { + emptySet() + }, saveState = local.saveState, visibleFields = visibleFields, hiddenFields = (offerableFields - visibleFields).sorted(), @@ -331,20 +342,24 @@ class EventEditViewModel @Inject constructor( if (_form.value != null) return val zone = TimeZone.currentSystemDefault() val now = Clock.System.now() - val start = when { + val startAt = when { startMinutes != null -> - LocalDateTime(date, LocalTime(startMinutes / 60, startMinutes % 60)) + LocalDateTime(date, LocalTime(startMinutes / 60, startMinutes % 60)).toInstant(zone) date == now.toLocalDateTime(zone).date -> { // Today: the next full hour (may roll into tomorrow before midnight). val hourMillis = 3_600_000L - val rounded = (now.toEpochMilliseconds() / hourMillis + 1) * hourMillis - Instant.fromEpochMilliseconds(rounded).toLocalDateTime(zone) + Instant.fromEpochMilliseconds((now.toEpochMilliseconds() / hourMillis + 1) * hourMillis) } - else -> LocalDateTime(date, LocalTime(9, 0)) + else -> LocalDateTime(date, LocalTime(9, 0)).toInstant(zone) } - val end = (start.toInstant(zone) + SettingsPrefs.DEFAULT_EVENT_DURATION.minutes) - .toLocalDateTime(zone) - _form.value = EventForm(calendarId = null, start = start, end = end) + val endAt = startAt + SettingsPrefs.DEFAULT_EVENT_DURATION.minutes + _form.value = EventForm( + calendarId = null, + start = startAt.toLocalDateTime(zone), + end = endAt.toLocalDateTime(zone), + startOffset = zone.offsetAt(startAt), + endOffset = zone.offsetAt(endAt), + ) applyDefaultReminder() applyDefaultDuration() } @@ -454,11 +469,10 @@ class EventEditViewModel @Inject constructor( if (_editTarget.value != null || _durationTouched.value) return@launch if (_form.value?.isAllDay != false) return@launch val duration = resolveDefaultEventDuration(global, overrides, targetId) - val zone = TimeZone.currentSystemDefault() update { form -> - form.copy( - end = (form.start.toInstant(zone) + duration.minutes).toLocalDateTime(zone), - ) + val zone = form.resolvedZone(TimeZone.currentSystemDefault()) + val start = form.startInstant(zone) + form.withTimes(start, start + duration.minutes, zone) } } } @@ -663,12 +677,12 @@ class EventEditViewModel @Inject constructor( // default duration, so a later calendar switch keeps their span (#54). fun setEndDate(date: LocalDate) { _durationTouched.value = true - update { it.copy(end = LocalDateTime(date, it.end.time)) } + update { it.withEnd(LocalDateTime(date, it.end.time)) } } fun setEndTime(time: LocalTime) { _durationTouched.value = true - update { it.copy(end = LocalDateTime(it.end.date, time)) } + update { it.withEnd(LocalDateTime(it.end.date, time)) } } /** @@ -681,7 +695,7 @@ class EventEditViewModel @Inject constructor( val current = state.value ?: return if (current.saveState == SaveUiState.Saving) return val form = current.form - if (form.problems().isNotEmpty()) { + if (form.problems(TimeZone.currentSystemDefault()).isNotEmpty()) { _showProblems.value = true return } @@ -810,11 +824,13 @@ class EventEditViewModel @Inject constructor( } private fun moveStart(transform: (LocalDateTime) -> LocalDateTime) = update { form -> - val zone = TimeZone.currentSystemDefault() - val newStart = transform(form.start) - val duration = form.end.toInstant(zone) - form.start.toInstant(zone) - val newEnd = (newStart.toInstant(zone) + duration).toLocalDateTime(zone) - form.copy(start = newStart, end = newEnd) + val zone = form.resolvedZone(TimeZone.currentSystemDefault()) + 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 }) + form.withTimes(newStart, newStart + duration, zone) } private inline fun update(block: (EventForm) -> EventForm) { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index 55fc236b..ed3e4e9c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -52,6 +52,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.key @@ -299,6 +300,12 @@ internal val CalendarView.timelineKind: TimelineKind else -> TimelineKind.Week } +/** + * The zone the shown timeline is laid out in, so its blocks, drags and drops all + * read the same wall clock (#387). + */ +internal val LocalTimelineZone = staticCompositionLocalOf { TimeZone.currentSystemDefault() } + /** How long a switch waits for the view it goes to before resizing into it without its events. */ private const val MORPH_DATA_WAIT_MS = 150L @@ -435,7 +442,8 @@ private fun PagedTimelineScreen( // Off the ticking clock rather than the loaded page, so it turns over at // midnight; derived, so the per-minute tick doesn't recompose the screen. - val zone = remember { TimeZone.currentSystemDefault() } + // The timeline's own zone, so "today" agrees with the blocks and drops (#387). + val zone = viewModel.zone val today by remember(zone) { derivedStateOf { nowState.value.toLocalDateTime(zone).date } } val isOnCurrentWeek = state !is WeekUiState.Success || (today >= weekStart && today <= weekEnd) @@ -476,7 +484,10 @@ private fun PagedTimelineScreen( ) ScreenBody(modifier = modifier) { innerPadding -> - CompositionLocalProvider(LocalDimCutoff provides dimCutoff) { + CompositionLocalProvider( + LocalDimCutoff provides dimCutoff, + LocalTimelineZone provides viewModel.zone, + ) { WeekContent( state = state, today = today, @@ -527,7 +538,7 @@ private fun WeekContent( // inside the outgoing page. val dragController = rememberTimelineDragController() val move = LocalEventMove.current - val zone = remember { TimeZone.currentSystemDefault() } + val zone = LocalTimelineZone.current val onDrop: (TimelineDrop) -> Unit = { drop -> // A selected event stays selected where it landed (#141). if (dragController.selection?.event?.instanceId == drop.event.instanceId) { @@ -1369,7 +1380,7 @@ private fun DayColumnCard( val hourPx = with(density) { hourHeight.toPx() } val morphing = LocalViewMorph.current != null val showHourGrid = LocalShowHourGrid.current - val zone = remember { TimeZone.currentSystemDefault() } + val zone = LocalTimelineZone.current // Tells a settled drop when this column has caught up with it. LaunchedEffect(blocks, dragController.settling) { dragController.noteGrid(date, blocks) @@ -1524,8 +1535,10 @@ private fun EventBlock( val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) } val use24Hour = LocalUse24HourFormat.current val locale = currentLocale() + val zone = LocalTimelineZone.current + val labelEndMin = remember(block, date, zone) { block.labelEndMin(date, zone) } val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" + - minToHm(block.endMin, use24Hour, locale) + minToHm(labelEndMin, use24Hour, locale) val density = LocalDensity.current val metrics = rememberBlockTextMetrics(height) // Only full-width (non-overlapping) blocks that are tall enough show the @@ -1539,7 +1552,7 @@ private fun EventBlock( val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2 val shownTime = rememberBlockTimeRange( minToHm(block.startMin, use24Hour, locale), - minToHm(block.endMin, use24Hour, locale), + minToHm(labelEndMin, use24Hour, locale), textWidth, ) // A short block drops the title rather than serving a horizontally sliced @@ -1570,7 +1583,6 @@ private fun EventBlock( val timeMaxLines = if (showTime) blockTimeLines(shownTime, textWidth, spare) else 1 val dimCutoff = LocalDimCutoff.current val dimmed = dimCutoff != null && block.event.isPastOn(date, dimCutoff) - val zone = remember { TimeZone.currentSystemDefault() } val moveAction = eventMoveAction(block.event) val draggable = eventDragAllowed(block.event) val selectable = eventSelectAllowed() @@ -1591,6 +1603,7 @@ private fun EventBlock( dragController.begin( block = block, clipOffsetMin = clipOffset, + zone = zone, titleLines = if (showTitle) titleMaxLines else 0, pointerInRoot = pointer, // Measured off the block's placement rather than off where the diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt index 5561e973..8f6d6f21 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt @@ -35,6 +35,7 @@ import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DayOfWeek import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone +import kotlinx.datetime.toJavaZoneId import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.atTime import kotlinx.datetime.daysUntil @@ -81,7 +82,8 @@ abstract class PagedTimelineViewModel( initialRule: (LocalDate) -> PageGrid, ) : ViewModel() { - private val zone = TimeZone.currentSystemDefault() + /** The zone every page is laid out in; drags and drops resolve in it too (#387). */ + val zone: TimeZone = TimeZone.currentSystemDefault() /** Whether to fade events that have already finished (display concern only). */ val dimCompletedEvents: StateFlow = settingsPrefs.dimCompletedEvents @@ -331,6 +333,62 @@ internal fun spanRange(start: LocalDate, count: Int, zone: TimeZone): ClosedRang return from..to } +/** + * This instant's wall-clock time as minutes past [day]'s midnight in [zone] — + * negative before that day, past 1440 after it. Elapsed time since midnight would + * put everything after a DST change an hour off on that day's grid (#387). + */ +internal fun Instant.minutesPast(day: LocalDate, zone: TimeZone): Int { + val local = toLocalDateTime(zone) + val days = (local.date.toEpochDays() - day.toEpochDays()).toInt() + return days * MINUTES_PER_DAY + local.time.toSecondOfDay() / 60 +} + +/** [instant]'s [minutesPast] clamped to [day]. */ +internal fun wallClockMinutes(instant: Instant, day: LocalDate, zone: TimeZone): Int = + instant.minutesPast(day, zone).coerceIn(0, MINUTES_PER_DAY) + +/** + * Where a run from [start] to [end] that begins at [startMin] ends on [day]'s + * wall clock. One ending in the fall-back hour's repeat can read as ending before + * it began: it starts in that hour's first pass, so it covers the hour's wall + * clock from its start to where the hour ends, and is drawn to there. + */ +internal fun wallClockEndMinutes( + startMin: Int, + start: Instant, + end: Instant, + day: LocalDate, + zone: TimeZone, +): Int { + val wall = end.minutesPast(day, zone) + if (wall > startMin || end <= start) return maxOf(wall, startMin) + return repeatedHourEndMinutes(end, day, zone) ?: (startMin + (end - start).inWholeMinutes.toInt()) +} + +/** + * Where the repeated hour [end] falls in ends, as minutes past [day]'s midnight: + * the wall clock the zone fell back from (03:00 in Berlin). Null when the last + * change before [end] was no fall-back. + */ +private fun repeatedHourEndMinutes(end: Instant, day: LocalDate, zone: TimeZone): Int? { + // Just past [end], so an end on the transition itself still finds it. + val after = java.time.Instant.ofEpochMilli(end.toEpochMilliseconds() + 1) + val transition = zone.toJavaZoneId().rules.previousTransition(after) + ?.takeIf { it.isOverlap } ?: return null + val local = transition.dateTimeBefore + val days = (local.toLocalDate().toEpochDay() - day.toEpochDays()).toInt() + return days * MINUTES_PER_DAY + local.toLocalTime().toSecondOfDay() / 60 +} + +/** + * The end a block's time label names on [day]: its event's own wall-clock end, + * which for one ending in the fall-back hour's repeat is earlier than the + * [TimedBlock.endMin] it is drawn to (#387). + */ +internal fun TimedBlock.labelEndMin(day: LocalDate, zone: TimeZone): Int = + minOf(endMin, wallClockMinutes(event.end, day, zone)) + /** True if this event overlaps the calendar [day] in [zone] (any portion). */ internal fun EventInstance.coversDay(day: LocalDate, zone: TimeZone): Boolean { if (isAllDay) { @@ -384,14 +442,14 @@ internal fun layoutDay( if (ev.start == ev.end) { // Instant event: keep only if the point falls inside this day. if (ev.start < dayStart || ev.start >= dayEnd) return@mapNotNull null - val m = (ev.start - dayStart).inWholeMinutes.toInt().coerceIn(0, MINUTES_PER_DAY) + val m = wallClockMinutes(ev.start, day, zone) Raw(ev, m, m) } else { val s = maxOf(ev.start, dayStart) val e = minOf(ev.end, dayEnd) if (e <= s) return@mapNotNull null - val startMin = (s - dayStart).inWholeMinutes.toInt().coerceIn(0, MINUTES_PER_DAY) - val endMin = (e - dayStart).inWholeMinutes.toInt().coerceIn(startMin, MINUTES_PER_DAY) + val startMin = wallClockMinutes(s, day, zone) + val endMin = wallClockEndMinutes(startMin, s, e, day, zone).coerceAtMost(MINUTES_PER_DAY) Raw(ev, startMin, endMin) } } 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 db59c958..7a68ff47 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 @@ -9,8 +9,10 @@ import de.jeanlucmakiola.calendula.domain.realignRecurrence import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalTime +import kotlinx.datetime.UtcOffset import org.junit.jupiter.api.Test import java.time.ZoneId +import kotlin.time.Instant class EventWriteMapperTest { @@ -1387,4 +1389,37 @@ class EventWriteMapperTest { assertThat(parent[CalendarContract.Events.DTSTART]).isEqualTo(1_780_560_000_000L) assertThat(parent[CalendarContract.Events.RRULE]).isEqualTo("FREQ=WEEKLY") } + + @Test + 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)) + .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()) + } + + @Test + fun `a move between the two repeated fall-back hours writes the new times`() { + // 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)) + val values = update(original, updated) + assertThat(values[CalendarContract.Events.DTSTART]) + .isEqualTo(Instant.parse("2026-10-25T01:30:00Z").toEpochMilliseconds()) + assertThat(values[CalendarContract.Events.DTEND]) + .isEqualTo(Instant.parse("2026-10-25T01:30:00Z").toEpochMilliseconds()) + } + + @Test + 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)) + 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 b2fb0580..a99a8ffa 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt @@ -19,25 +19,25 @@ class EventFormTest { @Test fun `valid timed form has no problems`() { - assertThat(form().problems()).isEmpty() + assertThat(form().problems(berlin)).isEmpty() } @Test fun `missing calendar is a problem`() { - assertThat(form(calendarId = null).problems()) + assertThat(form(calendarId = null).problems(berlin)) .containsExactly(EventFormProblem.NoCalendar) } @Test fun `timed end before start is a problem`() { val bad = form(end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(9, 0))) - assertThat(bad.problems()).containsExactly(EventFormProblem.EndBeforeStart) + assertThat(bad.problems(berlin)).containsExactly(EventFormProblem.EndBeforeStart) } @Test fun `zero-length timed event is allowed`() { val instant = form(end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0))) - assertThat(instant.problems()).isEmpty() + assertThat(instant.problems(berlin)).isEmpty() } @Test @@ -47,7 +47,7 @@ class EventFormTest { start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)), end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)), ) - assertThat(allDay.problems()).isEmpty() + assertThat(allDay.problems(berlin)).isEmpty() } @Test @@ -57,7 +57,7 @@ class EventFormTest { start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)), end = LocalDateTime(LocalDate(2026, 6, 10), LocalTime(0, 0)), ) - assertThat(bad.problems()).containsExactly(EventFormProblem.EndBeforeStart) + assertThat(bad.problems(berlin)).containsExactly(EventFormProblem.EndBeforeStart) } @Test @@ -66,7 +66,7 @@ class EventFormTest { calendarId = null, end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(9, 0)), ) - assertThat(bad.problems()).containsExactly( + assertThat(bad.problems(berlin)).containsExactly( EventFormProblem.NoCalendar, EventFormProblem.EndBeforeStart, ) @@ -76,28 +76,28 @@ class EventFormTest { fun `recurrence until before the first day is a problem`() { // Days before the start, so it parses to an earlier date in any zone. val bad = form().copy(rrule = "FREQ=DAILY;UNTIL=20260601T120000Z") - assertThat(bad.problems()) + assertThat(bad.problems(berlin)) .containsExactly(EventFormProblem.RecurrenceEndsBeforeStart) } @Test fun `recurrence until on or after the first day is fine`() { // Date-only UNTIL parses zone-independently. - assertThat(form().copy(rrule = "FREQ=DAILY;UNTIL=20260611").problems()).isEmpty() - assertThat(form().copy(rrule = "FREQ=WEEKLY").problems()).isEmpty() + assertThat(form().copy(rrule = "FREQ=DAILY;UNTIL=20260611").problems(berlin)).isEmpty() + assertThat(form().copy(rrule = "FREQ=WEEKLY").problems(berlin)).isEmpty() } @Test fun `complex rrules are not validated against the start`() { // The picker can't have produced this ("second Monday" ordinal BYDAY); // it is preserved verbatim and never flagged. - assertThat(form().copy(rrule = "FREQ=MONTHLY;BYDAY=2MO;UNTIL=20200101").problems()).isEmpty() + assertThat(form().copy(rrule = "FREQ=MONTHLY;BYDAY=2MO;UNTIL=20200101").problems(berlin)).isEmpty() } @Test fun `weekly byday rules are validated against the start`() { val bad = form().copy(rrule = "FREQ=WEEKLY;BYDAY=MO,FR;UNTIL=20200101") - assertThat(bad.problems()) + assertThat(bad.problems(berlin)) .containsExactly(EventFormProblem.RecurrenceEndsBeforeStart) } @@ -210,64 +210,23 @@ class EventFormTest { } @Test - fun `timesIn converts a pinned event into the target zone`() { - val form = EventForm( - calendarId = 1L, - start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(8, 0)), - end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(9, 0)), - timezone = "America/New_York", - ) - val (start, end) = form.timesIn(berlin)!! - // 08:00 New York (EDT, UTC-4) is 14:00 Berlin (CEST, UTC+2). - assertThat(start).isEqualTo(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(14, 0))) - assertThat(end).isEqualTo(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(15, 0))) - } - - @Test - fun `timesIn crosses the date line when the offset pushes past midnight`() { - val form = EventForm( - calendarId = 1L, - start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(20, 0)), - end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(21, 0)), - timezone = "America/New_York", - ) - val (start, _) = form.timesIn(berlin)!! - // 20:00 New York is 02:00 the NEXT day in Berlin — the date has to move - // with it, not just the clock. - assertThat(start).isEqualTo(LocalDateTime(LocalDate(2026, 7, 18), LocalTime(2, 0))) - } - - @Test - fun `timesIn tracks each zone's own DST rather than a fixed offset`() { - // In January both are on standard time: 08:00 EST is 14:00 CET — the - // same six hours as July, but only because both shifted. Late March, - // when the US has sprung forward and Europe hasn't, the gap is five. - val march = EventForm( - calendarId = 1L, - start = LocalDateTime(LocalDate(2026, 3, 20), LocalTime(8, 0)), - end = LocalDateTime(LocalDate(2026, 3, 20), LocalTime(9, 0)), - timezone = "America/New_York", - ) - assertThat(march.timesIn(berlin)!!.first) - .isEqualTo(LocalDateTime(LocalDate(2026, 3, 20), LocalTime(13, 0))) - } - - @Test - fun `timesIn returns null when there is nothing to disambiguate`() { + 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)), ) // Unpinned: the form's times already are the local times. - assertThat(base.timesIn(berlin)).isNull() + assertThat(base.isPinnedAwayFrom(berlin)).isFalse() // Pinned to the target itself: same thing. - assertThat(base.copy(timezone = "Europe/Berlin").timesIn(berlin)).isNull() + assertThat(base.copy(timezone = "Europe/Berlin").isPinnedAwayFrom(berlin)).isFalse() // All-day: date-anchored, so there's no zone conversion to show. - assertThat(base.copy(isAllDay = true, timezone = "America/New_York").timesIn(berlin)) - .isNull() + assertThat(base.copy(isAllDay = true, timezone = "America/New_York").isPinnedAwayFrom(berlin)) + .isFalse() // Unparseable: can't convert, mustn't throw. - assertThat(base.copy(timezone = "Mars/Olympus_Mons").timesIn(berlin)).isNull() + assertThat(base.copy(timezone = "Mars/Olympus_Mons").isPinnedAwayFrom(berlin)).isFalse() + // Pinned elsewhere: worth spelling out. + assertThat(base.copy(timezone = "America/New_York").isPinnedAwayFrom(berlin)).isTrue() } @Test @@ -425,4 +384,43 @@ class EventFormTest { assertThat(prefilled.color).isNull() assertThat(prefilled.populatedFields()).doesNotContain(EventFormField.Color) } + + @Test + fun `toEditForm keeps which repeated fall-back hour each end is in`() { + 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.startInstant(berlin)).isEqualTo(start) + assertThat(prefilled.endInstant(berlin)).isEqualTo(end) + } + + @Test + fun `a span ending in the repeated fall-back hour is valid`() { + // 02:45 CEST to 02:15 CET: half an hour, though the clock reads backwards. + val form = form().withTimes( + Instant.parse("2026-10-25T00:45:00Z"), + Instant.parse("2026-10-25T01:15:00Z"), + berlin, + ) + assertThat(form.end).isLessThan(form.start) + 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)) + .containsExactly(EventFormProblem.EndBeforeStart) + } + + @Test + fun `an end picked by hand takes the earlier repeated hour`() { + val form = form().withTimes( + Instant.parse("2026-10-25T00:45:00Z"), + Instant.parse("2026-10-25T01:15:00Z"), + berlin, + ) + // Picking the end it already has keeps its pass. + assertThat(form.withEnd(form.end)).isEqualTo(form) + val picked = form.withEnd(LocalDateTime(LocalDate(2026, 10, 25), LocalTime(2, 50))) + assertThat(picked.endOffset).isNull() + assertThat(picked.endInstant(berlin)).isEqualTo(Instant.parse("2026-10-25T00:50: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 a626b7ab..d26a5e39 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventShiftTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventShiftTest.kt @@ -5,8 +5,10 @@ import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalTime import kotlinx.datetime.TimeZone +import kotlinx.datetime.UtcOffset import kotlinx.datetime.toInstant import org.junit.jupiter.api.Test +import kotlin.time.Instant class EventShiftTest { @@ -88,8 +90,10 @@ class EventShiftTest { @Test fun `a zero-distance drop leaves the form untouched`() { - val original = form() - assertThat(original.shiftedTo(original.start.toInstant(berlin), berlin)).isEqualTo(original) + // As read from the provider, offsets and all. + val start = form().start.toInstant(berlin) + val original = form().withTimes(start, form().end.toInstant(berlin), berlin) + assertThat(original.shiftedTo(start, berlin)).isEqualTo(original) } @Test @@ -197,4 +201,35 @@ class EventShiftTest { assertThat(allDay.resizedTo(start, start, berlin)).isEqualTo(allDay) } + + // 02:00 CEST to 02:00 CET on 2026-10-25: one real hour, one wall-clock value. + private val repeatStart = Instant.parse("2026-10-25T00:00:00Z") + private val repeatEnd = Instant.parse("2026-10-25T01:00:00Z") + + @Test + fun `moving an event in the repeated fall-back hour keeps its length`() { + val original = form().withTimes(repeatStart, repeatEnd, berlin) + val moved = original.shiftedTo(Instant.parse("2026-10-25T00:30:00Z"), berlin) + assertThat(moved.startInstant(berlin)).isEqualTo(Instant.parse("2026-10-25T00:30:00Z")) + assertThat(moved.endInstant(berlin)).isEqualTo(Instant.parse("2026-10-25T01:30:00Z")) + } + + @Test + 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.startInstant(berlin)).isEqualTo(target) + } + + @Test + fun `resolveIn takes the preferred offset only where the hour repeats`() { + val repeated = LocalDateTime(LocalDate(2026, 10, 25), LocalTime(2, 30)) + val cet = UtcOffset(hours = 1) + assertThat(repeated.resolveIn(berlin, cet)).isEqualTo(Instant.parse("2026-10-25T01:30:00Z")) + assertThat(repeated.resolveIn(berlin, null)).isEqualTo(Instant.parse("2026-10-25T00:30:00Z")) + // Not one of 14:00's offsets on a summer day: ignored. + val summer = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(14, 0)) + assertThat(summer.resolveIn(berlin, cet)).isEqualTo(summer.toInstant(berlin)) + } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RepeatedHourZoneTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RepeatedHourZoneTest.kt new file mode 100644 index 00000000..0571d730 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RepeatedHourZoneTest.kt @@ -0,0 +1,30 @@ +package de.jeanlucmakiola.calendula.ui.common + +import com.google.common.truth.Truth.assertThat +import kotlinx.datetime.TimeZone +import org.junit.jupiter.api.Test +import java.util.Locale +import kotlin.time.Instant + +/** The zone named after a time only where the fall-back hour repeats it (#387). */ +class RepeatedHourZoneTest { + + private val brussels = TimeZone.of("Europe/Brussels") + + @Test + fun `both 02 30s of the fall-back day are told apart`() { + val first = withRepeatedHourZone("2:30 AM", Instant.parse("2026-10-25T00:30:00Z"), brussels, Locale.UK) + val second = withRepeatedHourZone("2:30 AM", Instant.parse("2026-10-25T01:30:00Z"), brussels, Locale.UK) + assertThat(first).startsWith("2:30 AM ") + assertThat(second).startsWith("2:30 AM ") + assertThat(first).isNotEqualTo(second) + } + + @Test + fun `an unambiguous time stays bare`() { + assertThat(withRepeatedHourZone("2:45 PM", Instant.parse("2026-10-25T13:45:00Z"), brussels, Locale.UK)) + .isEqualTo("2:45 PM") + assertThat(withRepeatedHourZone("3:30 AM", Instant.parse("2026-10-25T02:30:00Z"), brussels, Locale.UK)) + .isEqualTo("3:30 AM") + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineDropTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineDropTest.kt index 9ee1646a..e9cf383c 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineDropTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineDropTest.kt @@ -4,12 +4,15 @@ import com.google.common.truth.Truth.assertThat import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.ui.week.TimedBlock import de.jeanlucmakiola.calendula.ui.week.layoutDay +import de.jeanlucmakiola.calendula.ui.week.minutesPast import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone import kotlinx.datetime.atTime import kotlinx.datetime.plus import kotlinx.datetime.toInstant +import kotlin.time.Duration.Companion.hours +import kotlin.time.Instant import org.junit.jupiter.api.Test /** @@ -153,4 +156,88 @@ class TimelineDropTest { val block = layoutDay(listOf(plain), sat, zone).single() assertThat(block.clipOffsetMinutes(sat, zone)).isEqualTo(0) } + + private val brussels = TimeZone.of("Europe/Brussels") + private val fallBack = LocalDate(2026, 10, 25) + + @Test + fun `a block on the fall-back day carries no clip offset`() { + // #387: 13:45Z is 14:45 CET, drawn and picked up at 14:45. + val event = overnight.copy( + start = Instant.parse("2026-10-25T13:45:00Z"), + end = Instant.parse("2026-10-25T15:45:00Z"), + ) + val block = layoutDay(listOf(event), fallBack, brussels).single() + assertThat(block.clipOffsetMinutes(fallBack, brussels)).isEqualTo(0) + val drop = TimelineDrop(event, fallBack, block.startMin, clipOffsetMin = 0) + assertThat(drop.startInstant(brussels)).isEqualTo(event.start) + } + + @Test + fun `a tail on the fall-back day is offset by wall-clock time`() { + val event = overnight.copy( + start = LocalDate(2026, 10, 24).atTime(22, 0).toInstant(brussels), + end = fallBack.atTime(6, 0).toInstant(brussels), + ) + val tail = layoutDay(listOf(event), fallBack, brussels).single() + val offset = tail.clipOffsetMinutes(fallBack, brussels) + assertThat(offset).isEqualTo(2 * 60) + val drop = TimelineDrop(event, fallBack, tail.startMin, clipOffsetMin = offset) + assertThat(drop.startInstant(brussels)).isEqualTo(event.start) + } + + @Test + fun `wall-clock span follows the clock change`() { + // 01:00 to 04:00 on the fall-back day is four real hours. + assertThat(wallClockSpanMinutes(fallBack, 60, 4.hours, brussels)).isEqualTo(3 * 60) + // 01:00 to 04:00 on the spring-forward day is two. + assertThat(wallClockSpanMinutes(LocalDate(2026, 3, 29), 60, 2.hours, brussels)).isEqualTo(3 * 60) + assertThat(wallClockSpanMinutes(sat, 20 * 60, 12.hours, zone)).isEqualTo(12 * 60) + } + + @Test + fun `a drop in the repeated fall-back hour keeps the event's offset`() { + // 02:30 CET, the second 02:30 of the day, dragged 15 minutes later. + val event = overnight.copy( + start = Instant.parse("2026-10-25T01:30:00Z"), + end = Instant.parse("2026-10-25T02:30:00Z"), + ) + val block = layoutDay(listOf(event), fallBack, brussels).single() + val drop = TimelineDrop(event, fallBack, block.startMin + 15, clipOffsetMin = 0) + assertThat(drop.startInstant(brussels)).isEqualTo(Instant.parse("2026-10-25T01:45:00Z")) + } + + @Test + fun `an ambiguous time without an offset to keep takes the earlier one`() { + assertThat(wallClockInstant(fallBack, 2 * 60 + 45, brussels)) + .isEqualTo(Instant.parse("2026-10-25T00:45:00Z")) + } + + @Test + fun `a start in the spring-forward gap resolves an hour on`() { + val springForward = LocalDate(2026, 3, 29) + val landed = wallClockInstant(springForward, 2 * 60 + 30, brussels, keepOffsetOf = overnight.start) + assertThat(landed.minutesPast(springForward, brussels)).isEqualTo(3 * 60 + 30) + assertThat(wallClockSpanMinutes(springForward, 3 * 60 + 30, 1.hours, brussels)).isEqualTo(60) + } + + @Test + fun `a span ending in the repeated fall-back hour reaches that hour's end`() { + // 02:00 CEST plus one hour is 02:00 CET; 02:30 CEST plus one is 02:30 CET. + assertThat(wallClockSpanMinutes(fallBack, 2 * 60, 1.hours, brussels)).isEqualTo(60) + assertThat(wallClockSpanMinutes(fallBack, 2 * 60 + 30, 1.hours, brussels)).isEqualTo(30) + } + + @Test + fun `a drop onto the repeated fall-back hour doesn't depend on the event's season`() { + val slot = 2 * 60 + 30 + val first = Instant.parse("2026-10-25T00:30:00Z") + val july = Instant.parse("2026-07-01T10:00:00Z") + val november = Instant.parse("2026-11-10T10:00:00Z") + assertThat(wallClockInstant(fallBack, slot, brussels, keepOffsetOf = july)).isEqualTo(first) + assertThat(wallClockInstant(fallBack, slot, brussels, keepOffsetOf = november)).isEqualTo(first) + // Later the same day, after the clocks went back: still no say. + val evening = Instant.parse("2026-10-25T18:00:00Z") + assertThat(wallClockInstant(fallBack, slot, brussels, keepOffsetOf = evening)).isEqualTo(first) + } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekLayoutTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekLayoutTest.kt index 3c30d2dd..94553bb1 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekLayoutTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekLayoutTest.kt @@ -190,5 +190,108 @@ class WeekLayoutTest { assertThat(spans.all { it.lane == 0 }).isTrue() } + // Europe/Brussels falls back on 2026-10-25 (25 h) and springs forward on 2026-03-29 (23 h). + private val brussels = TimeZone.of("Europe/Brussels") + + private fun brusselsEvent(date: LocalDate, from: Pair, to: Pair) = event( + date.atTime(from.first, from.second).toInstant(brussels), + date.atTime(to.first, to.second).toInstant(brussels), + ) + + @Test + fun `timed block sits at its wall-clock time on the fall-back day`() { + val day = LocalDate(2026, 10, 25) + // The event from #387: 13:45Z is 14:45 CET. + val ev = event(Instant.parse("2026-10-25T13:45:00Z"), Instant.parse("2026-10-25T15:45:00Z")) + val b = layoutDay(listOf(ev), day, brussels).single() + assertThat(b.startMin).isEqualTo(14 * 60 + 45) + assertThat(b.endMin).isEqualTo(16 * 60 + 45) + } + + @Test + fun `timed block sits at its wall-clock time on the spring-forward day`() { + val day = LocalDate(2026, 3, 29) + val b = layoutDay(listOf(brusselsEvent(day, 14 to 45, 16 to 45)), day, brussels).single() + assertThat(b.startMin).isEqualTo(14 * 60 + 45) + assertThat(b.endMin).isEqualTo(16 * 60 + 45) + } + + @Test + fun `late block on the fall-back day is not cut short`() { + val day = LocalDate(2026, 10, 25) + val b = layoutDay(listOf(brusselsEvent(day, 22 to 30, 23 to 59)), day, brussels).single() + assertThat(b.startMin).isEqualTo(22 * 60 + 30) + assertThat(b.endMin).isEqualTo(23 * 60 + 59) + } + + @Test + fun `block running past midnight on the fall-back day reaches the day's end`() { + val day = LocalDate(2026, 10, 25) + val ev = event( + day.atTime(23, 0).toInstant(brussels), + day.plusDays(1).atTime(1, 0).toInstant(brussels), + ) + val b = layoutDay(listOf(ev), day, brussels).single() + assertThat(b.startMin).isEqualTo(23 * 60) + assertThat(b.endMin).isEqualTo(MINUTES_PER_DAY) + } + + @Test + fun `instant event on the fall-back day sits at its wall-clock time`() { + val day = LocalDate(2026, 10, 25) + val at = day.atTime(9, 0).toInstant(brussels) + val b = layoutDay(listOf(event(at, at)), day, brussels).single() + assertThat(b.startMin).isEqualTo(9 * 60) + assertThat(b.endMin).isEqualTo(9 * 60) + } + + @Test + fun `block ending in the repeated fall-back hour is drawn to that hour's end`() { + val day = LocalDate(2026, 10, 25) + // 02:00 CEST to 02:00 CET: one real hour, the same wall-clock time at both ends. + val ev = event(Instant.parse("2026-10-25T00:00:00Z"), Instant.parse("2026-10-25T01:00:00Z")) + val b = layoutDay(listOf(ev), day, brussels).single() + assertThat(b.startMin).isEqualTo(2 * 60) + assertThat(b.endMin).isEqualTo(3 * 60) + } + + @Test + fun `block ending in the repeated fall-back hour leaves the next hour free`() { + val day = LocalDate(2026, 10, 25) + // 02:15 CEST to 02:15 CET, then 03:00 CET: an hour apart in real time. + val repeat = event(Instant.parse("2026-10-25T00:15:00Z"), Instant.parse("2026-10-25T01:15:00Z")) + val next = event(Instant.parse("2026-10-25T02:00:00Z"), Instant.parse("2026-10-25T03:00:00Z")) + val blocks = layoutDay(listOf(repeat, next), day, brussels) + assertThat(blocks.map { it.startMin to it.endMin }) + .containsExactly(2 * 60 + 15 to 3 * 60, 3 * 60 to 4 * 60) + assertThat(blocks.all { it.lane == 0 && it.laneCount == 1 }).isTrue() + } + + @Test + fun `minutesPast reads wall clock either side of the day`() { + val day = LocalDate(2026, 10, 25) + assertThat(Instant.parse("2026-10-24T21:00:00Z").minutesPast(day, brussels)).isEqualTo(-60) + assertThat(Instant.parse("2026-10-25T13:45:00Z").minutesPast(day, brussels)).isEqualTo(14 * 60 + 45) + assertThat(Instant.parse("2026-10-25T23:30:00Z").minutesPast(day, brussels)) + .isEqualTo(MINUTES_PER_DAY + 30) + } + + @Test + fun `a block ending in the repeated fall-back hour is labelled with its own end`() { + val day = LocalDate(2026, 10, 25) + // 02:30 CEST to 02:30 CET: drawn to the repeated hour's end, but it ends at 02:30. + val ev = event(Instant.parse("2026-10-25T00:30:00Z"), Instant.parse("2026-10-25T01:30:00Z")) + val b = layoutDay(listOf(ev), day, brussels).single() + assertThat(b.endMin).isEqualTo(3 * 60) + assertThat(b.labelEndMin(day, brussels)).isEqualTo(2 * 60 + 30) + } + + @Test + fun `an ordinary block is labelled with its drawn end`() { + val day = LocalDate(2026, 10, 25) + val b = layoutDay(listOf(brusselsEvent(day, 14 to 45, 16 to 45)), day, brussels).single() + assertThat(b.labelEndMin(day, brussels)).isEqualTo(b.endMin) + } + private fun LocalDate.plusDays(n: Int): LocalDate = plus(n, DateTimeUnit.DAY) }