Keep timeline events in the right repeated hour on DST fall-back day (#387)

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
This commit is contained in:
2026-10-12 23:24:23 +02:00
parent dce83005ea
commit e2b35b3fac
20 changed files with 733 additions and 202 deletions
@@ -1,5 +1,7 @@
package de.jeanlucmakiola.calendula.data.calendar package de.jeanlucmakiola.calendula.data.calendar
import de.jeanlucmakiola.calendula.domain.resolveIn
import kotlinx.datetime.toKotlinTimeZone
import android.provider.CalendarContract import android.provider.CalendarContract
import de.jeanlucmakiola.calendula.domain.AccessLevel import de.jeanlucmakiola.calendula.domain.AccessLevel
import de.jeanlucmakiola.calendula.domain.Availability import de.jeanlucmakiola.calendula.domain.Availability
@@ -48,8 +50,8 @@ internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDa
} else { } else {
val writeZone = writeZone(zone) val writeZone = writeZone(zone)
EventWriteTimes( EventWriteTimes(
dtStartMillis = start.toJavaLocalDateTime().atZone(writeZone).toInstant().toEpochMilli(), dtStartMillis = start.resolveIn(writeZone.toKotlinTimeZone(), startOffset).toEpochMilliseconds(),
dtEndMillis = end.toJavaLocalDateTime().atZone(writeZone).toInstant().toEpochMilli(), dtEndMillis = end.resolveIn(writeZone.toKotlinTimeZone(), endOffset).toEpochMilliseconds(),
timezone = writeZone.id, timezone = writeZone.id,
) )
} }
@@ -181,12 +183,14 @@ internal fun buildEventUpdateValues(
// A zone change counts as a time change even when the wall-clock is // 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 // 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 || val timesChanged = updated.start != original.start ||
updated.end != original.end || updated.end != original.end ||
updated.isAllDay != original.isAllDay || updated.isAllDay != original.isAllDay ||
updated.rrule != original.rrule || updated.rrule != original.rrule ||
updated.timezone != original.timezone updated.timezone != original.timezone ||
updated.toWriteTimes(zone) != original.toWriteTimes(zone)
if (!timesChanged) return@buildMap if (!timesChanged) return@buildMap
val newTimes = updated.toWriteTimes(zone) val newTimes = updated.toWriteTimes(zone)
@@ -4,7 +4,8 @@ import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant import kotlinx.datetime.UtcOffset
import kotlinx.datetime.offsetAt
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant import kotlin.time.Instant
@@ -36,6 +37,13 @@ data class EventForm(
* (see [EventFormField.Timezone] and the data layer's UTC-midnight rule). * (see [EventFormField.Timezone] and the data layer's UTC-midnight rule).
*/ */
val timezone: String? = null, 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 location: String = "",
val description: String = "", val description: String = "",
/** Reminder lead times in minutes before the start, deduplicated. */ /** 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 } ?.takeIf { runCatching { TimeZone.of(it) }.isSuccess }
} }
val formZone = pinnedZone?.let { TimeZone.of(it) } ?: zone 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 (start, end) = if (instance.isAllDay) {
val startDate = Instant.fromEpochMilliseconds(beginMillis) val startDate = begin.toLocalDateTime(TimeZone.UTC).date
.toLocalDateTime(TimeZone.UTC).date val endExclusive = finish.toLocalDateTime(TimeZone.UTC).date
val endExclusive = Instant.fromEpochMilliseconds(endMillis)
.toLocalDateTime(TimeZone.UTC).date
val endDate = maxOf(startDate, LocalDate.fromEpochDays(endExclusive.toEpochDays() - 1)) val endDate = maxOf(startDate, LocalDate.fromEpochDays(endExclusive.toEpochDays() - 1))
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0)) LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
} else { } else {
Instant.fromEpochMilliseconds(beginMillis).toLocalDateTime(formZone) to begin.toLocalDateTime(formZone) to finish.toLocalDateTime(formZone)
Instant.fromEpochMilliseconds(endMillis).toLocalDateTime(formZone)
} }
return EventForm( return EventForm(
calendarId = instance.calendarId, calendarId = instance.calendarId,
@@ -163,6 +170,8 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
start = start, start = start,
end = end, end = end,
timezone = pinnedZone, timezone = pinnedZone,
startOffset = if (instance.isAllDay) null else formZone.offsetAt(begin),
endOffset = if (instance.isAllDay) null else formZone.offsetAt(finish),
location = instance.location.orEmpty(), location = instance.location.orEmpty(),
description = description.orEmpty(), description = description.orEmpty(),
reminders = reminders.map { it.minutes }.distinct().sorted(), 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 * Whether this form's times mean something else in [target] — a timed event
* actually means where the user is standing. Null when there's nothing to * pinned to another zone. False when there's nothing to disambiguate: an
* disambiguate: an unpinned event (already in [target]), one pinned to [target] * unpinned event (already in [target]), one pinned to [target] itself, an
* itself, an all-day event (no zone), or an unparseable pinned zone. * 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 * The form edits a pinned event in its own zone, so this is what tells the UI
* show the other side of the pair rather than making the user do the arithmetic. * to show the other side of the pair rather than making the user do the arithmetic.
*/ */
fun EventForm.timesIn(target: TimeZone): Pair<LocalDateTime, LocalDateTime>? { fun EventForm.isPinnedAwayFrom(target: TimeZone): Boolean {
if (isAllDay) return null if (isAllDay) return false
val pinned = timezone?.let { runCatching { TimeZone.of(it) }.getOrNull() } ?: return null val pinned = timezone?.let { runCatching { TimeZone.of(it) }.getOrNull() } ?: return false
if (pinned.id == target.id) return null return pinned.id != target.id
return start.toInstant(pinned).toLocalDateTime(target) to
end.toInstant(pinned).toLocalDateTime(target)
} }
/** /**
@@ -254,9 +261,19 @@ fun EventForm.populatedFields(): Set<EventFormField> = buildSet {
if (attendees.isNotEmpty()) add(EventFormField.Attendees) if (attendees.isNotEmpty()) add(EventFormField.Attendees)
} }
fun EventForm.problems(): Set<EventFormProblem> = 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<EventFormProblem> = buildSet {
if (calendarId == null) add(EventFormProblem.NoCalendar) 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) if (endsTooEarly) add(EventFormProblem.EndBeforeStart)
// An UNTIL before the first day would make the provider generate zero // An UNTIL before the first day would make the provider generate zero
// occurrences — the event would silently vanish from every view. // occurrences — the event would silently vanish from every view.
@@ -3,10 +3,14 @@ package de.jeanlucmakiola.calendula.domain
import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.UtcOffset
import kotlinx.datetime.offsetAt
import kotlinx.datetime.plus import kotlinx.datetime.plus
import kotlinx.datetime.toInstant import kotlinx.datetime.toInstant
import kotlinx.datetime.toJavaZoneId
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant import kotlin.time.Instant
import kotlin.time.toJavaInstant
/** /**
* The zone this form's wall-clock times mean, matching what the data layer * 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 = fun EventForm.resolvedZone(deviceZone: TimeZone): TimeZone =
timezone?.let { runCatching { TimeZone.of(it) }.getOrNull() } ?: deviceZone 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 — * 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 * 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 { fun EventForm.shiftedTo(newStart: Instant, deviceZone: TimeZone): EventForm {
if (isAllDay) return this if (isAllDay) return this
val zone = resolvedZone(deviceZone) val zone = resolvedZone(deviceZone)
val span = end.toInstant(zone) - start.toInstant(zone) val span = endInstant(zone) - startInstant(zone)
return copy( return withTimes(newStart, newStart + span, zone)
start = newStart.toLocalDateTime(zone),
end = (newStart + span).toLocalDateTime(zone),
)
} }
/** /**
@@ -48,16 +86,13 @@ fun EventForm.shiftedByDays(days: Int, deviceZone: TimeZone): EventForm {
) )
} }
val zone = resolvedZone(deviceZone) val zone = resolvedZone(deviceZone)
val span = end.toInstant(zone) - start.toInstant(zone) val span = endInstant(zone) - startInstant(zone)
return copy(start = newStart, end = (newStart.toInstant(zone) + span).toLocalDateTime(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. */ /** 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 { fun EventForm.resizedTo(newStart: Instant, newEnd: Instant, deviceZone: TimeZone): EventForm {
if (isAllDay) return this if (isAllDay) return this
val zone = resolvedZone(deviceZone) return withTimes(newStart, newEnd, resolvedZone(deviceZone))
return copy(
start = newStart.toLocalDateTime(zone),
end = newEnd.toLocalDateTime(zone),
)
} }
@@ -4,7 +4,7 @@ import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant import kotlinx.datetime.offsetAt
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.hours
import kotlin.time.Instant import kotlin.time.Instant
@@ -35,7 +35,15 @@ fun buildInsertEventForm(
zone: TimeZone, zone: TimeZone,
now: Instant, now: Instant,
): EventForm { ): 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 // 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 // the last covered day and keep placeholder wall-clock times in case the
// user switches the event to timed (mirrors EventDetail.toEditForm). // user switches the event to timed (mirrors EventDetail.toEditForm).
@@ -48,14 +56,7 @@ fun buildInsertEventForm(
?: startDate ?: startDate
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0)) LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
} else { } else {
val startTime = beginMillis timed.first.toLocalDateTime(zone) to timed.second.toLocalDateTime(zone)
?.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
} }
return EventForm( return EventForm(
calendarId = null, calendarId = null,
@@ -63,6 +64,8 @@ fun buildInsertEventForm(
isAllDay = isAllDay, isAllDay = isAllDay,
start = start, start = start,
end = end, end = end,
startOffset = timed?.let { zone.offsetAt(it.first) },
endOffset = timed?.let { zone.offsetAt(it.second) },
location = location.orEmpty(), location = location.orEmpty(),
description = description.orEmpty(), description = description.orEmpty(),
// Bare RRULE value (Events.RRULE convention); tolerate a leading "RRULE:" // 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 hourMillis = 3_600_000L
val rounded = (now.toEpochMilliseconds() / hourMillis + 1) * hourMillis return Instant.fromEpochMilliseconds((now.toEpochMilliseconds() / hourMillis + 1) * hourMillis)
return Instant.fromEpochMilliseconds(rounded).toLocalDateTime(zone)
} }
@@ -157,12 +157,18 @@ fun timeZoneOptionOf(
* the offset isn't stated twice in ICU's spelling and ours. * the offset isn't stated twice in ICU's spelling and ours.
*/ */
fun zoneDescriptor(option: TimeZoneOption): String { 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 val abbrev = option.shortName
return when { return if (abbrev.startsWith("GMT")) formatGmtOffset(option.offsetMinutes) else abbrev
abbrev == "UTC" -> "UTC"
abbrev.startsWith("GMT") -> formatGmtOffset(option.offsetMinutes)
else -> "$abbrev · ${formatGmtOffset(option.offsetMinutes)}"
}
} }
/** /**
@@ -5,6 +5,7 @@ import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.offsetAt
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
/** /**
@@ -34,6 +35,8 @@ fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm {
isAllDay = isAllDay, isAllDay = isAllDay,
start = start, start = start,
end = end, end = end,
startOffset = if (isAllDay) null else zone.offsetAt(this.start),
endOffset = if (isAllDay) null else zone.offsetAt(this.end),
location = location.orEmpty(), location = location.orEmpty(),
description = description.orEmpty(), description = description.orEmpty(),
reminders = semanticReminderMinutes(), reminders = semanticReminderMinutes(),
@@ -16,6 +16,8 @@ import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence
import de.jeanlucmakiola.calendula.domain.problems import de.jeanlucmakiola.calendula.domain.problems
import de.jeanlucmakiola.calendula.domain.realignRecurrence import de.jeanlucmakiola.calendula.domain.realignRecurrence
import de.jeanlucmakiola.calendula.domain.resizedTo 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.resolvedZone
import de.jeanlucmakiola.calendula.domain.shiftedByDays import de.jeanlucmakiola.calendula.domain.shiftedByDays
import de.jeanlucmakiola.calendula.domain.shiftedTo 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 // The UNTIL check is deferred to the write, which knows how far the move
// reaches and so which date has to clear it. // 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 _outcome.value = MoveOutcome.Failed
return null return null
} }
@@ -495,8 +497,7 @@ class RescheduleViewModel @Inject constructor(
private fun resizedEndMillis(prepared: PreparedMove): Long? { private fun resizedEndMillis(prepared: PreparedMove): Long? {
if (prepared.request.target !is MoveTarget.Span) return null if (prepared.request.target !is MoveTarget.Span) return null
val form = prepared.updated val form = prepared.updated
return form.end.toInstant(form.resolvedZone(TimeZone.currentSystemDefault())) return form.endInstant(form.resolvedZone(TimeZone.currentSystemDefault())).toEpochMilliseconds()
.toEpochMilliseconds()
} }
/** /**
@@ -508,8 +509,7 @@ class RescheduleViewModel @Inject constructor(
return if (form.isAllDay) { return if (form.isAllDay) {
form.start.date.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds() form.start.date.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds()
} else { } else {
form.start.toInstant(form.resolvedZone(TimeZone.currentSystemDefault())) form.startInstant(form.resolvedZone(TimeZone.currentSystemDefault())).toEpochMilliseconds()
.toEpochMilliseconds()
} }
} }
@@ -1,7 +1,13 @@
package de.jeanlucmakiola.calendula.ui.common package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.staticCompositionLocalOf 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 java.time.LocalTime
import kotlin.time.Instant
import kotlin.time.toJavaInstant
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.util.Locale 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 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)}"
}
@@ -45,8 +45,12 @@ import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance 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.MINUTES_PER_DAY
import de.jeanlucmakiola.calendula.ui.week.TimedBlock 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.identity.rememberReduceMotion
import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.floret.locale.currentLocale
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -54,6 +58,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.offsetAt
import kotlinx.datetime.plus import kotlinx.datetime.plus
import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime import kotlinx.datetime.LocalTime
@@ -61,6 +66,7 @@ import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlin.math.roundToInt import kotlin.math.roundToInt
import kotlin.time.Duration
import kotlin.time.Instant import kotlin.time.Instant
/** Start granularity a dragged block snaps to, matching the big calendar apps. */ /** 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. * finger is on; this is what turns where it lands back into an event start.
*/ */
private var clipOffsetMin = 0 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]. */ /** What the picked-up block drew its title over — see [TimelineDrag.titleLines]. */
private var titleLines = 0 private var titleLines = 0
@@ -539,6 +547,7 @@ class TimelineDragController {
fun begin( fun begin(
block: TimedBlock, block: TimedBlock,
clipOffsetMin: Int, clipOffsetMin: Int,
zone: TimeZone,
titleLines: Int, titleLines: Int,
pointerInRoot: Offset, pointerInRoot: Offset,
blockInRoot: Offset, blockInRoot: Offset,
@@ -547,6 +556,8 @@ class TimelineDragController {
if (!isSelected(block)) deselect() if (!isSelected(block)) deselect()
source = block source = block
this.clipOffsetMin = clipOffsetMin this.clipOffsetMin = clipOffsetMin
dragZone = zone
dragDuration = (block.event.end - block.event.start).coerceAtLeast(Duration.ZERO)
this.titleLines = titleLines this.titleLines = titleLines
settling = null settling = null
isDragging = true isDragging = true
@@ -573,6 +584,8 @@ class TimelineDragController {
resizeSlot = null resizeSlot = null
source = null source = null
clipOffsetMin = 0 clipOffsetMin = 0
dragZone = null
dragDuration = Duration.ZERO
titleLines = 0 titleLines = 0
isDragging = false isDragging = false
pointerMoved = false pointerMoved = false
@@ -664,17 +677,12 @@ class TimelineDragController {
pointerInRoot: Offset, pointerInRoot: Offset,
) { ) {
val start = block.event.start.toLocalDateTime(zone) val start = block.event.start.toLocalDateTime(zone)
val end = block.event.end.toLocalDateTime(zone)
source = block source = block
resizeEdge = edge resizeEdge = edge
resizeDay = start.date resizeDay = start.date
resizeStartMin = start.time.toSecondOfDay() / 60 resizeStartMin = start.time.toSecondOfDay() / 60
// Wall clock at both ends, as the drop resolves them (DST-safe). // Wall clock at both ends, as the drop resolves them (DST-safe).
resizeEndMin = maxOf( resizeEndMin = wallClockEndMinutes(resizeStartMin, block.event.start, block.event.end, start.date, zone)
resizeStartMin,
(end.date.toEpochDays() - start.date.toEpochDays()).toInt() * MINUTES_PER_DAY +
end.time.toSecondOfDay() / 60,
)
clipOffsetMin = 0 clipOffsetMin = 0
this.titleLines = titleLines this.titleLines = titleLines
settling = null settling = null
@@ -701,6 +709,7 @@ class TimelineDragController {
fun finishResize(zone: TimeZone): TimelineResizeDrop? { fun finishResize(zone: TimeZone): TimelineResizeDrop? {
val landed = drag val landed = drag
val origin = originSlot val origin = originSlot
val edge = resizeEdge
cancel() cancel()
if (landed == null || landed.slot == origin) return null if (landed == null || landed.slot == origin) return null
settlingSelected = selection?.event?.instanceId == landed.event.instanceId settlingSelected = selection?.event?.instanceId == landed.event.instanceId
@@ -708,10 +717,26 @@ class TimelineDragController {
liftedInstanceId = landed.event.instanceId liftedInstanceId = landed.event.instanceId
settledOrigin = landed.event.eventId to landed.event.start settledOrigin = landed.event.eventId to landed.event.start
settledAbandonedMark = abandonedTicks?.value ?: 0 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( val drop = TimelineResizeDrop(
event = landed.event, event = landed.event,
start = wallClockInstant(landed.date, landed.eventStartMin, zone), start = if (edge == ResizeEdge.Top) {
end = wallClockInstant(landed.date, landed.eventStartMin + landed.eventSpanMin, zone), 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 settledEnd = drop.end
return drop return drop
@@ -835,13 +860,22 @@ class TimelineDragController {
val origin = grid.positionInRoot() val origin = grid.positionInRoot()
val rawMinutes = (pointer.y - grab.y - origin.y) / hourPx * 60f val rawMinutes = (pointer.y - grab.y - origin.y) / hourPx * 60f
val snapped = (rawMinutes / DRAG_SNAP_MINUTES).roundToInt() * DRAG_SNAP_MINUTES val snapped = (rawMinutes / DRAG_SNAP_MINUTES).roundToInt() * DRAG_SNAP_MINUTES
val eventSpan = (block.event.end - block.event.start).inWholeMinutes.toInt() val zone = dragZone ?: return
.coerceAtLeast(0)
val floor = dragFloorMin(clipOffsetMin, eventSpan)
val startMin = snapped.coerceIn(floor, MINUTES_PER_DAY - DRAG_SNAP_MINUTES)
val eventStartMin = startMin - clipOffsetMin
val column = ((pointer.x - origin.x) / columnPx).toInt().coerceIn(0, days.lastIndex) val column = ((pointer.x - origin.x) / columnPx).toInt().coerceIn(0, days.lastIndex)
val dayIndex = if (geometry.isRtl) days.lastIndex - column else column 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 visible = -dayIndex..days.lastIndex - dayIndex
val onGrid = dragSlices(eventStartMin, eventSpan, within = visible) val onGrid = dragSlices(eventStartMin, eventSpan, within = visible)
val slices = onGrid.ifEmpty { val slices = onGrid.ifEmpty {
@@ -929,10 +963,7 @@ fun rememberTimelineDragController(): TimelineDragController {
* so the offset has to come back off at the drop (#253). * so the offset has to come back off at the drop (#253).
*/ */
fun TimedBlock.clipOffsetMinutes(day: LocalDate, zone: TimeZone): Int { fun TimedBlock.clipOffsetMinutes(day: LocalDate, zone: TimeZone): Int {
val start = event.start.toLocalDateTime(zone) return (startMin - event.start.minutesPast(day, zone)).coerceAtLeast(0)
val days = (day.toEpochDays() - start.date.toEpochDays()).toInt()
val minutes = days * MINUTES_PER_DAY + startMin - start.time.toSecondOfDay() / 60
return minutes.coerceAtLeast(0)
} }
/** /**
@@ -954,13 +985,41 @@ fun TimedBlock.continuesAfter(day: LocalDate, zone: TimeZone): Boolean =
* visibly settles there. * visibly settles there.
*/ */
fun TimelineDrop.startInstant(zone: TimeZone): Instant = 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 day = date.plus(minutes.floorDiv(MINUTES_PER_DAY), DateTimeUnit.DAY)
val minute = minutes.mod(MINUTES_PER_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)
} }
/** /**
@@ -102,6 +102,7 @@ import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter 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.recurrenceText
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -807,12 +808,13 @@ private fun formatWhen(
} }
val endLdt = instance.end.toJavaLocalDateTime(zid) 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()) { return if (startLdt.toLocalDate() == endLdt.toLocalDate()) {
dateFull.format(startLdt.toLocalDate()) to dateFull.format(startLdt.toLocalDate()) to "$startTime – $endTime"
"${timeShort.format(startLdt)} – ${timeShort.format(endLdt)}"
} else { } else {
val start = "${dateMedium.format(startLdt.toLocalDate())} ${timeShort.format(startLdt)}" val start = "${dateMedium.format(startLdt.toLocalDate())} $startTime"
val end = "${dateMedium.format(endLdt.toLocalDate())} ${timeShort.format(endLdt)}" val end = "${dateMedium.format(endLdt.toLocalDate())} $endTime"
"$start – $end" to null "$start – $end" to null
} }
} }
@@ -116,10 +116,13 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventAttendee import de.jeanlucmakiola.calendula.domain.EventAttendee
import de.jeanlucmakiola.calendula.domain.EventColorOption import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventForm 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.EventFormField
import de.jeanlucmakiola.calendula.domain.zoneDescriptor import de.jeanlucmakiola.calendula.domain.zoneDescriptor
import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
import de.jeanlucmakiola.calendula.domain.timesIn
import de.jeanlucmakiola.calendula.domain.EventFormProblem import de.jeanlucmakiola.calendula.domain.EventFormProblem
import de.jeanlucmakiola.calendula.domain.RecurrenceEnd import de.jeanlucmakiola.calendula.domain.RecurrenceEnd
import de.jeanlucmakiola.calendula.domain.RecurrenceFreq 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.floret.locale.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter 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.reminderLeadTimeLabel
import de.jeanlucmakiola.calendula.ui.common.reminderUnitLabel import de.jeanlucmakiola.calendula.ui.common.reminderUnitLabel
import de.jeanlucmakiola.calendula.ui.common.nextOccurrencesText import de.jeanlucmakiola.calendula.ui.common.nextOccurrencesText
@@ -174,7 +178,6 @@ import kotlinx.datetime.TimeZone
import kotlinx.datetime.isoDayNumber import kotlinx.datetime.isoDayNumber
import kotlinx.datetime.toJavaDayOfWeek import kotlinx.datetime.toJavaDayOfWeek
import kotlinx.datetime.toJavaLocalDate import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.toInstant
import kotlinx.datetime.toJavaLocalTime import kotlinx.datetime.toJavaLocalTime
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlin.time.toJavaInstant import kotlin.time.toJavaInstant
@@ -184,6 +187,7 @@ import java.time.format.FormatStyle
import java.time.format.TextStyle as JavaTextStyle import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale import java.util.Locale
import kotlin.time.Clock import kotlin.time.Clock
import kotlin.time.Instant
/** /**
* Full-screen event form: create (v1.2, from the FAB's anchor date) and edit * 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)) 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( ScheduleRow(
label = stringResource(R.string.event_edit_starts), label = stringResource(R.string.event_edit_starts),
dateTime = form.start, dateTime = form.start,
instant = startAt,
zone = formZone,
isAllDay = form.isAllDay, isAllDay = form.isAllDay,
locale = locale, locale = locale,
onDateClick = { picker = PickerTarget.StartDate }, onDateClick = { picker = PickerTarget.StartDate },
@@ -674,6 +683,8 @@ private fun EventEditContent(
ScheduleRow( ScheduleRow(
label = stringResource(R.string.event_edit_ends), label = stringResource(R.string.event_edit_ends),
dateTime = form.end, dateTime = form.end,
instant = endAt,
zone = formZone,
isAllDay = form.isAllDay, isAllDay = form.isAllDay,
locale = locale, locale = locale,
onDateClick = { picker = PickerTarget.EndDate }, 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 // 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 // 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 // whoever is reading it. Spell the local equivalent out rather than
// leaving the user to do the offset arithmetic. Absent (null) unless // leaving the user to do the offset arithmetic. Absent unless the
// the event is pinned somewhere other than here. // event is pinned somewhere other than here.
// Keyed rather than recomputed: this sits in the same Column as the if (form.isPinnedAwayFrom(TimeZone.currentSystemDefault())) {
// 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) ->
Spacer(Modifier.height(2.dp)) Spacer(Modifier.height(2.dp))
Text( Text(
text = stringResource( text = stringResource(
R.string.event_edit_timezone_local_time, R.string.event_edit_timezone_local_time,
formatTimeRange(localStart, localEnd, locale), formatTimeRange(startAt, endAt, TimeZone.currentSystemDefault(), locale),
), ),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -769,7 +774,7 @@ private fun EventEditContent(
timeZoneOptionOf( timeZoneOptionOf(
id, id,
locale, locale,
at = form.start.toInstant(zone).toJavaInstant(), at = form.startInstant(zone).toJavaInstant(),
regionOf = ::icuTimeZoneRegion, 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 * "2:00 PM – 3:00 PM" in [zone], honouring the user's 12/24-hour setting. Collapses
* time when both land on the same minute (an instant event shouldn't read as a * to one time for an instant event, which shouldn't read as a range against
* range against itself). Dates are deliberately absent — the rows above carry * itself. Dates are deliberately absent — the rows above carry them.
* them.
*/ */
@Composable @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 use24Hour = LocalUse24HourFormat.current
val timeFormat = remember(locale, use24Hour) { timeOfDayFormatter(use24Hour, locale) } val timeFormat = remember(locale, use24Hour) { timeOfDayFormatter(use24Hour, locale) }
val from = timeFormat.format(start.time.toJavaLocalTime()) fun format(at: Instant) = withRepeatedHourZone(
val to = timeFormat.format(end.time.toJavaLocalTime()) timeFormat.format(at.toLocalDateTime(zone).time.toJavaLocalTime()),
return if (from == to) from else "$from – $to" at,
zone,
locale,
)
return if (start == end) format(start) else "${format(start)} – ${format(end)}"
} }
/** /**
@@ -2344,6 +2352,8 @@ private fun firstLineHeight(
private fun ScheduleRow( private fun ScheduleRow(
label: String, label: String,
dateTime: LocalDateTime, dateTime: LocalDateTime,
instant: Instant,
zone: TimeZone,
isAllDay: Boolean, isAllDay: Boolean,
locale: Locale, locale: Locale,
onDateClick: () -> Unit, onDateClick: () -> Unit,
@@ -2386,7 +2396,7 @@ private fun ScheduleRow(
) )
if (!isAllDay) { if (!isAllDay) {
Text( Text(
text = timeFormat.format(dateTime.time.toJavaLocalTime()), text = withRepeatedHourZone(timeFormat.format(dateTime.time.toJavaLocalTime()), instant, zone, locale),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = valueColor, color = valueColor,
modifier = Modifier modifier = Modifier
@@ -19,6 +19,12 @@ import de.jeanlucmakiola.calendula.domain.EditSnapshot
import de.jeanlucmakiola.calendula.domain.EventAttendee import de.jeanlucmakiola.calendula.domain.EventAttendee
import de.jeanlucmakiola.calendula.domain.EventColorOption import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventForm 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.EventFormField
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.isEventTarget import de.jeanlucmakiola.calendula.domain.isEventTarget
@@ -49,6 +55,7 @@ import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.offsetAt
import kotlinx.datetime.toInstant import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.cancellation.CancellationException
@@ -289,7 +296,11 @@ class EventEditViewModel @Inject constructor(
EventEditUiState( EventEditUiState(
form = resolved, form = resolved,
calendars = pickerCalendars, calendars = pickerCalendars,
problems = if (local.showProblems) resolved.problems() else emptySet(), problems = if (local.showProblems) {
resolved.problems(TimeZone.currentSystemDefault())
} else {
emptySet()
},
saveState = local.saveState, saveState = local.saveState,
visibleFields = visibleFields, visibleFields = visibleFields,
hiddenFields = (offerableFields - visibleFields).sorted(), hiddenFields = (offerableFields - visibleFields).sorted(),
@@ -331,20 +342,24 @@ class EventEditViewModel @Inject constructor(
if (_form.value != null) return if (_form.value != null) return
val zone = TimeZone.currentSystemDefault() val zone = TimeZone.currentSystemDefault()
val now = Clock.System.now() val now = Clock.System.now()
val start = when { val startAt = when {
startMinutes != null -> startMinutes != null ->
LocalDateTime(date, LocalTime(startMinutes / 60, startMinutes % 60)) LocalDateTime(date, LocalTime(startMinutes / 60, startMinutes % 60)).toInstant(zone)
date == now.toLocalDateTime(zone).date -> { date == now.toLocalDateTime(zone).date -> {
// Today: the next full hour (may roll into tomorrow before midnight). // Today: the next full hour (may roll into tomorrow before midnight).
val hourMillis = 3_600_000L val hourMillis = 3_600_000L
val rounded = (now.toEpochMilliseconds() / hourMillis + 1) * hourMillis Instant.fromEpochMilliseconds((now.toEpochMilliseconds() / hourMillis + 1) * hourMillis)
Instant.fromEpochMilliseconds(rounded).toLocalDateTime(zone)
} }
else -> LocalDateTime(date, LocalTime(9, 0)) else -> LocalDateTime(date, LocalTime(9, 0)).toInstant(zone)
} }
val end = (start.toInstant(zone) + SettingsPrefs.DEFAULT_EVENT_DURATION.minutes) val endAt = startAt + SettingsPrefs.DEFAULT_EVENT_DURATION.minutes
.toLocalDateTime(zone) _form.value = EventForm(
_form.value = EventForm(calendarId = null, start = start, end = end) calendarId = null,
start = startAt.toLocalDateTime(zone),
end = endAt.toLocalDateTime(zone),
startOffset = zone.offsetAt(startAt),
endOffset = zone.offsetAt(endAt),
)
applyDefaultReminder() applyDefaultReminder()
applyDefaultDuration() applyDefaultDuration()
} }
@@ -454,11 +469,10 @@ class EventEditViewModel @Inject constructor(
if (_editTarget.value != null || _durationTouched.value) return@launch if (_editTarget.value != null || _durationTouched.value) return@launch
if (_form.value?.isAllDay != false) return@launch if (_form.value?.isAllDay != false) return@launch
val duration = resolveDefaultEventDuration(global, overrides, targetId) val duration = resolveDefaultEventDuration(global, overrides, targetId)
val zone = TimeZone.currentSystemDefault()
update { form -> update { form ->
form.copy( val zone = form.resolvedZone(TimeZone.currentSystemDefault())
end = (form.start.toInstant(zone) + duration.minutes).toLocalDateTime(zone), 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). // default duration, so a later calendar switch keeps their span (#54).
fun setEndDate(date: LocalDate) { fun setEndDate(date: LocalDate) {
_durationTouched.value = true _durationTouched.value = true
update { it.copy(end = LocalDateTime(date, it.end.time)) } update { it.withEnd(LocalDateTime(date, it.end.time)) }
} }
fun setEndTime(time: LocalTime) { fun setEndTime(time: LocalTime) {
_durationTouched.value = true _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 val current = state.value ?: return
if (current.saveState == SaveUiState.Saving) return if (current.saveState == SaveUiState.Saving) return
val form = current.form val form = current.form
if (form.problems().isNotEmpty()) { if (form.problems(TimeZone.currentSystemDefault()).isNotEmpty()) {
_showProblems.value = true _showProblems.value = true
return return
} }
@@ -810,11 +824,13 @@ class EventEditViewModel @Inject constructor(
} }
private fun moveStart(transform: (LocalDateTime) -> LocalDateTime) = update { form -> private fun moveStart(transform: (LocalDateTime) -> LocalDateTime) = update { form ->
val zone = TimeZone.currentSystemDefault() val zone = form.resolvedZone(TimeZone.currentSystemDefault())
val newStart = transform(form.start) val duration = form.endInstant(zone) - form.startInstant(zone)
val duration = form.end.toInstant(zone) - form.start.toInstant(zone) // A picked time can't say which pass of a repeated hour it means; only
val newEnd = (newStart.toInstant(zone) + duration).toLocalDateTime(zone) // an unchanged start keeps its own (see [withEnd]).
form.copy(start = newStart, end = newEnd) 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) { private inline fun update(block: (EventForm) -> EventForm) {
@@ -52,6 +52,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.key import androidx.compose.runtime.key
@@ -299,6 +300,12 @@ internal val CalendarView.timelineKind: TimelineKind
else -> TimelineKind.Week 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. */ /** 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 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 // 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. // 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 today by remember(zone) { derivedStateOf { nowState.value.toLocalDateTime(zone).date } }
val isOnCurrentWeek = state !is WeekUiState.Success || (today >= weekStart && today <= weekEnd) val isOnCurrentWeek = state !is WeekUiState.Success || (today >= weekStart && today <= weekEnd)
@@ -476,7 +484,10 @@ private fun PagedTimelineScreen(
) )
ScreenBody(modifier = modifier) { innerPadding -> ScreenBody(modifier = modifier) { innerPadding ->
CompositionLocalProvider(LocalDimCutoff provides dimCutoff) { CompositionLocalProvider(
LocalDimCutoff provides dimCutoff,
LocalTimelineZone provides viewModel.zone,
) {
WeekContent( WeekContent(
state = state, state = state,
today = today, today = today,
@@ -527,7 +538,7 @@ private fun WeekContent(
// inside the outgoing page. // inside the outgoing page.
val dragController = rememberTimelineDragController() val dragController = rememberTimelineDragController()
val move = LocalEventMove.current val move = LocalEventMove.current
val zone = remember { TimeZone.currentSystemDefault() } val zone = LocalTimelineZone.current
val onDrop: (TimelineDrop) -> Unit = { drop -> val onDrop: (TimelineDrop) -> Unit = { drop ->
// A selected event stays selected where it landed (#141). // A selected event stays selected where it landed (#141).
if (dragController.selection?.event?.instanceId == drop.event.instanceId) { if (dragController.selection?.event?.instanceId == drop.event.instanceId) {
@@ -1369,7 +1380,7 @@ private fun DayColumnCard(
val hourPx = with(density) { hourHeight.toPx() } val hourPx = with(density) { hourHeight.toPx() }
val morphing = LocalViewMorph.current != null val morphing = LocalViewMorph.current != null
val showHourGrid = LocalShowHourGrid.current 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. // Tells a settled drop when this column has caught up with it.
LaunchedEffect(blocks, dragController.settling) { LaunchedEffect(blocks, dragController.settling) {
dragController.noteGrid(date, blocks) dragController.noteGrid(date, blocks)
@@ -1524,8 +1535,10 @@ private fun EventBlock(
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) } val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
val use24Hour = LocalUse24HourFormat.current val use24Hour = LocalUse24HourFormat.current
val locale = currentLocale() val locale = currentLocale()
val zone = LocalTimelineZone.current
val labelEndMin = remember(block, date, zone) { block.labelEndMin(date, zone) }
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" + val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" +
minToHm(block.endMin, use24Hour, locale) minToHm(labelEndMin, use24Hour, locale)
val density = LocalDensity.current val density = LocalDensity.current
val metrics = rememberBlockTextMetrics(height) val metrics = rememberBlockTextMetrics(height)
// Only full-width (non-overlapping) blocks that are tall enough show the // 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 textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2
val shownTime = rememberBlockTimeRange( val shownTime = rememberBlockTimeRange(
minToHm(block.startMin, use24Hour, locale), minToHm(block.startMin, use24Hour, locale),
minToHm(block.endMin, use24Hour, locale), minToHm(labelEndMin, use24Hour, locale),
textWidth, textWidth,
) )
// A short block drops the title rather than serving a horizontally sliced // 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 timeMaxLines = if (showTime) blockTimeLines(shownTime, textWidth, spare) else 1
val dimCutoff = LocalDimCutoff.current val dimCutoff = LocalDimCutoff.current
val dimmed = dimCutoff != null && block.event.isPastOn(date, dimCutoff) val dimmed = dimCutoff != null && block.event.isPastOn(date, dimCutoff)
val zone = remember { TimeZone.currentSystemDefault() }
val moveAction = eventMoveAction(block.event) val moveAction = eventMoveAction(block.event)
val draggable = eventDragAllowed(block.event) val draggable = eventDragAllowed(block.event)
val selectable = eventSelectAllowed() val selectable = eventSelectAllowed()
@@ -1591,6 +1603,7 @@ private fun EventBlock(
dragController.begin( dragController.begin(
block = block, block = block,
clipOffsetMin = clipOffset, clipOffsetMin = clipOffset,
zone = zone,
titleLines = if (showTitle) titleMaxLines else 0, titleLines = if (showTitle) titleMaxLines else 0,
pointerInRoot = pointer, pointerInRoot = pointer,
// Measured off the block's placement rather than off where the // Measured off the block's placement rather than off where the
@@ -35,6 +35,7 @@ import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.toJavaZoneId
import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.atTime import kotlinx.datetime.atTime
import kotlinx.datetime.daysUntil import kotlinx.datetime.daysUntil
@@ -81,7 +82,8 @@ abstract class PagedTimelineViewModel(
initialRule: (LocalDate) -> PageGrid, initialRule: (LocalDate) -> PageGrid,
) : ViewModel() { ) : 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). */ /** Whether to fade events that have already finished (display concern only). */
val dimCompletedEvents: StateFlow<Boolean> = settingsPrefs.dimCompletedEvents val dimCompletedEvents: StateFlow<Boolean> = settingsPrefs.dimCompletedEvents
@@ -331,6 +333,62 @@ internal fun spanRange(start: LocalDate, count: Int, zone: TimeZone): ClosedRang
return from..to 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). */ /** True if this event overlaps the calendar [day] in [zone] (any portion). */
internal fun EventInstance.coversDay(day: LocalDate, zone: TimeZone): Boolean { internal fun EventInstance.coversDay(day: LocalDate, zone: TimeZone): Boolean {
if (isAllDay) { if (isAllDay) {
@@ -384,14 +442,14 @@ internal fun layoutDay(
if (ev.start == ev.end) { if (ev.start == ev.end) {
// Instant event: keep only if the point falls inside this day. // Instant event: keep only if the point falls inside this day.
if (ev.start < dayStart || ev.start >= dayEnd) return@mapNotNull null 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) Raw(ev, m, m)
} else { } else {
val s = maxOf(ev.start, dayStart) val s = maxOf(ev.start, dayStart)
val e = minOf(ev.end, dayEnd) val e = minOf(ev.end, dayEnd)
if (e <= s) return@mapNotNull null if (e <= s) return@mapNotNull null
val startMin = (s - dayStart).inWholeMinutes.toInt().coerceIn(0, MINUTES_PER_DAY) val startMin = wallClockMinutes(s, day, zone)
val endMin = (e - dayStart).inWholeMinutes.toInt().coerceIn(startMin, MINUTES_PER_DAY) val endMin = wallClockEndMinutes(startMin, s, e, day, zone).coerceAtMost(MINUTES_PER_DAY)
Raw(ev, startMin, endMin) Raw(ev, startMin, endMin)
} }
} }
@@ -9,8 +9,10 @@ import de.jeanlucmakiola.calendula.domain.realignRecurrence
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime import kotlinx.datetime.LocalTime
import kotlinx.datetime.UtcOffset
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import java.time.ZoneId import java.time.ZoneId
import kotlin.time.Instant
class EventWriteMapperTest { class EventWriteMapperTest {
@@ -1387,4 +1389,37 @@ class EventWriteMapperTest {
assertThat(parent[CalendarContract.Events.DTSTART]).isEqualTo(1_780_560_000_000L) assertThat(parent[CalendarContract.Events.DTSTART]).isEqualTo(1_780_560_000_000L)
assertThat(parent[CalendarContract.Events.RRULE]).isEqualTo("FREQ=WEEKLY") 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)
}
} }
@@ -19,25 +19,25 @@ class EventFormTest {
@Test @Test
fun `valid timed form has no problems`() { fun `valid timed form has no problems`() {
assertThat(form().problems()).isEmpty() assertThat(form().problems(berlin)).isEmpty()
} }
@Test @Test
fun `missing calendar is a problem`() { fun `missing calendar is a problem`() {
assertThat(form(calendarId = null).problems()) assertThat(form(calendarId = null).problems(berlin))
.containsExactly(EventFormProblem.NoCalendar) .containsExactly(EventFormProblem.NoCalendar)
} }
@Test @Test
fun `timed end before start is a problem`() { fun `timed end before start is a problem`() {
val bad = form(end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(9, 0))) 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 @Test
fun `zero-length timed event is allowed`() { fun `zero-length timed event is allowed`() {
val instant = form(end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0))) val instant = form(end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)))
assertThat(instant.problems()).isEmpty() assertThat(instant.problems(berlin)).isEmpty()
} }
@Test @Test
@@ -47,7 +47,7 @@ class EventFormTest {
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)), start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)),
end = 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 @Test
@@ -57,7 +57,7 @@ class EventFormTest {
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)), start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)),
end = LocalDateTime(LocalDate(2026, 6, 10), 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 @Test
@@ -66,7 +66,7 @@ class EventFormTest {
calendarId = null, calendarId = null,
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(9, 0)), end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(9, 0)),
) )
assertThat(bad.problems()).containsExactly( assertThat(bad.problems(berlin)).containsExactly(
EventFormProblem.NoCalendar, EventFormProblem.NoCalendar,
EventFormProblem.EndBeforeStart, EventFormProblem.EndBeforeStart,
) )
@@ -76,28 +76,28 @@ class EventFormTest {
fun `recurrence until before the first day is a problem`() { fun `recurrence until before the first day is a problem`() {
// Days before the start, so it parses to an earlier date in any zone. // Days before the start, so it parses to an earlier date in any zone.
val bad = form().copy(rrule = "FREQ=DAILY;UNTIL=20260601T120000Z") val bad = form().copy(rrule = "FREQ=DAILY;UNTIL=20260601T120000Z")
assertThat(bad.problems()) assertThat(bad.problems(berlin))
.containsExactly(EventFormProblem.RecurrenceEndsBeforeStart) .containsExactly(EventFormProblem.RecurrenceEndsBeforeStart)
} }
@Test @Test
fun `recurrence until on or after the first day is fine`() { fun `recurrence until on or after the first day is fine`() {
// Date-only UNTIL parses zone-independently. // Date-only UNTIL parses zone-independently.
assertThat(form().copy(rrule = "FREQ=DAILY;UNTIL=20260611").problems()).isEmpty() assertThat(form().copy(rrule = "FREQ=DAILY;UNTIL=20260611").problems(berlin)).isEmpty()
assertThat(form().copy(rrule = "FREQ=WEEKLY").problems()).isEmpty() assertThat(form().copy(rrule = "FREQ=WEEKLY").problems(berlin)).isEmpty()
} }
@Test @Test
fun `complex rrules are not validated against the start`() { fun `complex rrules are not validated against the start`() {
// The picker can't have produced this ("second Monday" ordinal BYDAY); // The picker can't have produced this ("second Monday" ordinal BYDAY);
// it is preserved verbatim and never flagged. // 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 @Test
fun `weekly byday rules are validated against the start`() { fun `weekly byday rules are validated against the start`() {
val bad = form().copy(rrule = "FREQ=WEEKLY;BYDAY=MO,FR;UNTIL=20200101") val bad = form().copy(rrule = "FREQ=WEEKLY;BYDAY=MO,FR;UNTIL=20200101")
assertThat(bad.problems()) assertThat(bad.problems(berlin))
.containsExactly(EventFormProblem.RecurrenceEndsBeforeStart) .containsExactly(EventFormProblem.RecurrenceEndsBeforeStart)
} }
@@ -210,64 +210,23 @@ class EventFormTest {
} }
@Test @Test
fun `timesIn converts a pinned event into the target zone`() { fun `isPinnedAwayFrom is false when there is nothing to disambiguate`() {
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`() {
val base = EventForm( val base = EventForm(
calendarId = 1L, calendarId = 1L,
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(8, 0)), start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(8, 0)),
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(9, 0)), end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(9, 0)),
) )
// Unpinned: the form's times already are the local times. // 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. // 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. // All-day: date-anchored, so there's no zone conversion to show.
assertThat(base.copy(isAllDay = true, timezone = "America/New_York").timesIn(berlin)) assertThat(base.copy(isAllDay = true, timezone = "America/New_York").isPinnedAwayFrom(berlin))
.isNull() .isFalse()
// Unparseable: can't convert, mustn't throw. // 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 @Test
@@ -425,4 +384,43 @@ class EventFormTest {
assertThat(prefilled.color).isNull() assertThat(prefilled.color).isNull()
assertThat(prefilled.populatedFields()).doesNotContain(EventFormField.Color) 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"))
}
} }
@@ -5,8 +5,10 @@ import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.UtcOffset
import kotlinx.datetime.toInstant import kotlinx.datetime.toInstant
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import kotlin.time.Instant
class EventShiftTest { class EventShiftTest {
@@ -88,8 +90,10 @@ class EventShiftTest {
@Test @Test
fun `a zero-distance drop leaves the form untouched`() { fun `a zero-distance drop leaves the form untouched`() {
val original = form() // As read from the provider, offsets and all.
assertThat(original.shiftedTo(original.start.toInstant(berlin), berlin)).isEqualTo(original) val start = form().start.toInstant(berlin)
val original = form().withTimes(start, form().end.toInstant(berlin), berlin)
assertThat(original.shiftedTo(start, berlin)).isEqualTo(original)
} }
@Test @Test
@@ -197,4 +201,35 @@ class EventShiftTest {
assertThat(allDay.resizedTo(start, start, berlin)).isEqualTo(allDay) 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))
}
} }
@@ -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")
}
}
@@ -4,12 +4,15 @@ import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.week.TimedBlock import de.jeanlucmakiola.calendula.ui.week.TimedBlock
import de.jeanlucmakiola.calendula.ui.week.layoutDay import de.jeanlucmakiola.calendula.ui.week.layoutDay
import de.jeanlucmakiola.calendula.ui.week.minutesPast
import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.atTime import kotlinx.datetime.atTime
import kotlinx.datetime.plus import kotlinx.datetime.plus
import kotlinx.datetime.toInstant import kotlinx.datetime.toInstant
import kotlin.time.Duration.Companion.hours
import kotlin.time.Instant
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
/** /**
@@ -153,4 +156,88 @@ class TimelineDropTest {
val block = layoutDay(listOf(plain), sat, zone).single() val block = layoutDay(listOf(plain), sat, zone).single()
assertThat(block.clipOffsetMinutes(sat, zone)).isEqualTo(0) 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)
}
} }
@@ -190,5 +190,108 @@ class WeekLayoutTest {
assertThat(spans.all { it.lane == 0 }).isTrue() 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<Int, Int>, to: Pair<Int, Int>) = 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) private fun LocalDate.plusDays(n: Int): LocalDate = plus(n, DateTimeUnit.DAY)
} }