Show events at their real time on days the clocks change (#387) (#393)
Beta — Codeberg pre-release / detect (push) Successful in 6s
Beta — Codeberg pre-release / beta (push) Skipped

On the day the clocks change, the day, 3-day and week views placed events by the time elapsed since midnight, so every event after the change sat an hour off. A 14:45 event on 25 October showed at 15:45. Blocks are now placed by wall clock.

When the clocks go back, 02:00 to 03:00 happens twice, and a wall-clock time alone can't say which of the two it means. This branch keeps track of it:

- The event form holds each time as a `FormTime`, the wall clock plus its UTC offset when that time is in the repeated hour. Validation and saving read the real instants from it, so a span ending in the repeated hour is valid and switching between the two passes is saved.
- A time picked in the form or dragged with a resize handle goes back to the event's original pass when it's set back to its original time. Otherwise it moves in the direction it was moved, stays after the start, and takes a pass the grid can draw where it was dropped.
- A drop onto a repeated time lands on the first pass, unless the event was already on the second.
- A block that runs into the repeated hour from before it is drawn to 03:00, and its label shows its real end.
- The editor, the event details and the pinned zone's "local time" line add the zone abbreviation (CEST or CET) to a time in the repeated hour.
- A recurring series that starts in the repeated hour keeps its pass when it's edited.

Also in this PR:

- `ZoneSource`, provided through Hilt, gives the timeline, the event form and reschedules the device zone. The timeline lays its pages out again when the zone changes.
- A drafted block (#153) opens the form at its exact start and end.
- A day move onto the spring-forward day keeps its wall-clock start, so moving a weekly series there is still offered for all events.

## Testing

The 1,258 unit tests pass. The instrumented suite has the same two failures as `release/v2.23.0` (`CalendarRepositorySmokeTest` and `PermissionScreenTest`), so nothing new.

I also checked it by hand on an emulator in Europe/Berlin, with the clock moved to each DST day:

- 25 October: the event from the issue (14:45, Europe/Brussels) is drawn at 14:45. A run from 02:10 CEST to 02:20 CET is drawn to 03:00 and labelled with its real end.
- Pulling the end of a 02:30 to 03:30 CET event up to 02:45 saves 02:45 CET, after its start.
- Pulling the end of a 01:00 CEST to 04:00 CET event up to 02:15 saves 02:15 CEST, and the block stays where it was dropped.
- 29 March: a 01:00 to 04:00 run and a 14:00 event both sit at their own times.

Picking times in the form and moving a series in the month view are covered by unit tests only.

Closes #387

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/393
This commit is contained in:
Jean-Luc Makiola
2026-10-14 15:54:14 +02:00
co-authored by makiolaj
parent 9fe1ebb0fb
commit 0f75d2638b
31 changed files with 1575 additions and 381 deletions
+2
View File
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Scroll back to earlier days in the agenda.** Scroll up past today to see earlier days' events, loaded a month at a time. Past days follow the past-events setting: Dim fades them, and with Hide the agenda still starts at today ([#367]).
### Fixed
- **Events on the day the clocks change show at their real time.** In the day, 3-day and week views, events later on the day of a daylight saving change were drawn an hour off, so a 14:45 event showed at 15:45. They now sit at their own time. When the clocks go back, 02:00 to 03:00 happens twice: an event in that hour now stays in the right one when you move, resize or edit it, and the editor and event details add the zone, like CEST or CET, so you can tell the two apart ([#387]).
- **The agenda always starts today.** Coming from the month or day view, the agenda used to start at the day you had open there, so "Next 30 days" could show the 1st to the 30th of the month. It now opens on today with your chosen range. Backing out of a day you opened from the agenda still returns you to where you were ([#367]).
- **Multi-day events dim on the days already behind you.** With past events dimmed, an event running over several days stayed at full strength on its earlier days until it ended. Those days now dim in the agenda, month, week and day views ([#367]).
@@ -1863,3 +1864,4 @@ automatically, with zero telemetry and no internet permission.
[#141]: https://codeberg.org/jlmakiola/calendula/issues/141
[#153]: https://codeberg.org/jlmakiola/calendula/issues/153
[#339]: https://codeberg.org/jlmakiola/calendula/issues/339
[#387]: https://codeberg.org/jlmakiola/calendula/issues/387
@@ -1,15 +1,19 @@
package de.jeanlucmakiola.calendula.data.calendar
import de.jeanlucmakiola.calendula.domain.instantIn
import kotlinx.datetime.toKotlinTimeZone
import android.provider.CalendarContract
import de.jeanlucmakiola.calendula.domain.AccessLevel
import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.FormTime
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import de.jeanlucmakiola.calendula.domain.nearestTo
import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.toJavaLocalDateTime
import kotlinx.datetime.toJavaZoneOffset
import java.time.Duration
import java.time.Instant
import java.time.ZoneId
@@ -48,8 +52,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.instantIn(writeZone.toKotlinTimeZone()).toEpochMilliseconds(),
dtEndMillis = end.instantIn(writeZone.toKotlinTimeZone()).toEpochMilliseconds(),
timezone = writeZone.id,
)
}
@@ -75,7 +79,7 @@ private fun EventForm.writeZone(deviceZone: ZoneId): ZoneId = if (isAllDay) {
private fun EventForm.anchorLocal(): JavaLocalDateTime = if (isAllDay) {
start.date.toJavaLocalDate().atStartOfDay()
} else {
start.toJavaLocalDateTime()
start.dateTime.toJavaLocalDateTime()
}
/**
@@ -181,7 +185,8 @@ 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 each [FormTime] carries (#387).
val timesChanged = updated.start != original.start ||
updated.end != original.end ||
updated.isAllDay != original.isAllDay ||
@@ -209,15 +214,23 @@ internal fun buildEventUpdateValues(
// is a different offset from the series anchor's whenever a DST boundary
// sits between them, or whenever the zone itself changed. Working in wall
// clock keeps "09:00" meaning 09:00 at both ends.
val seriesLocal = Instant.ofEpochMilli(seriesDtStartMillis)
.atZone(original.writeZone(zone)).toLocalDateTime()
val seriesZoned = Instant.ofEpochMilli(seriesDtStartMillis).atZone(original.writeZone(zone))
val seriesLocal = seriesZoned.toLocalDateTime()
val wallClockShift = Duration.between(original.anchorLocal(), updated.anchorLocal())
val shifted = seriesLocal.plus(wallClockShift)
// An all-day series anchor must sit on a UTC midnight. A pure day move
// already lands there (both ends are midnights), but *switching* a
// recurring event to all-day shifts by a time-of-day too, so snap.
val newSeriesLocal = if (updated.isAllDay) shifted.toLocalDate().atStartOfDay() else shifted
val newSeriesStart = newSeriesLocal.atZone(updated.writeZone(zone))
// A wall clock the fall-back hour repeats keeps the anchor's own pass, or,
// when the anchor is the edited occurrence itself, the pass the form now
// holds: a bare wall clock would always take the earlier one (#387).
val pass = if (seriesLocal == original.anchorLocal()) {
updated.start.offset?.toJavaZoneOffset()
} else {
seriesZoned.offset
}
val newSeriesStart = ZonedDateTime.ofLocal(newSeriesLocal, updated.writeZone(zone), pass)
put(CalendarContract.Events.DTSTART, newSeriesStart.toInstant().toEpochMilli())
put(CalendarContract.Events.DTEND, null)
put(CalendarContract.Events.RRULE, updated.rrule)
@@ -8,6 +8,7 @@ import de.jeanlucmakiola.floret.reminders.ReminderOverride
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.FormTime
import de.jeanlucmakiola.calendula.domain.RecurrenceFreq
import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate
@@ -212,8 +213,8 @@ class SpecialDatesSyncEngine @Inject constructor(
calendarId = calendarId,
title = title,
isAllDay = true,
start = start,
end = start,
start = FormTime(start),
end = FormTime(start),
reminders = reminderCtx.resolveAllDay(calendarId),
availability = Availability.Free,
rrule = YEARLY_RRULE,
@@ -21,7 +21,9 @@ import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec
import de.jeanlucmakiola.calendula.data.reminders.ProviderReminderInstanceSource
import de.jeanlucmakiola.calendula.data.reminders.ReminderInstanceSource
import de.jeanlucmakiola.calendula.data.time.AndroidTodaySource
import de.jeanlucmakiola.calendula.data.time.AndroidZoneSource
import de.jeanlucmakiola.calendula.data.time.TodaySource
import de.jeanlucmakiola.calendula.data.time.ZoneSource
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import javax.inject.Singleton
@@ -69,6 +71,12 @@ abstract class DataBindModule {
abstract fun bindTodaySource(
impl: AndroidTodaySource,
): TodaySource
@Binds
@Singleton
abstract fun bindZoneSource(
impl: AndroidZoneSource,
): ZoneSource
}
@Module
@@ -0,0 +1,51 @@
package de.jeanlucmakiola.calendula.data.time
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.datetime.TimeZone
import javax.inject.Inject
/**
* The zone the device is in. Every screen that turns wall clock into instants
* reads it from here, so the timeline, the event form and a saved move use the
* same zone (#387).
*/
interface ZoneSource {
/** The device's zone, re-emitted whenever it changes. */
val zone: Flow<TimeZone>
/** The device's zone right now. */
fun current(): TimeZone
}
/** Follows the system's zone broadcast. */
class AndroidZoneSource @Inject constructor(
@ApplicationContext private val context: Context,
) : ZoneSource {
override val zone: Flow<TimeZone> = callbackFlow {
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
trySend(Unit)
}
}
val filter = IntentFilter(Intent.ACTION_TIMEZONE_CHANGED)
ContextCompat.registerReceiver(context, receiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED)
awaitClose { context.unregisterReceiver(receiver) }
}
.onStart { emit(Unit) }
.map { current() }
.distinctUntilChanged()
override fun current(): TimeZone = TimeZone.currentSystemDefault()
}
@@ -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.UtcOffset
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant
@@ -17,8 +17,8 @@ data class EventForm(
val calendarId: Long?,
val title: String = "",
val isAllDay: Boolean = false,
val start: LocalDateTime,
val end: LocalDateTime,
val start: FormTime,
val end: FormTime,
/**
* The zone [start]/[end] are wall-clock values in, or null to follow the
* device — null is not "no zone", it is "whichever zone the device is in
@@ -70,6 +70,19 @@ data class EventForm(
val attendees: List<EventAttendee> = emptyList(),
)
/**
* One end of a form's span: a wall-clock [dateTime] in the form's zone, and the
* [offset] it was read at when the fall-back hour repeats that wall clock — both
* of the hour's instants share one [LocalDateTime] (#387). Null everywhere else,
* so a time changed and changed back compares equal again. A time picked by hand
* gets its pass from [repicked]; one without an offset reads as the earlier
* pass. One value, so a time never changes without its pass. See [toFormTime].
*/
data class FormTime(val dateTime: LocalDateTime, val offset: UtcOffset? = null) {
val date: LocalDate get() = dateTime.date
val time: LocalTime get() = dateTime.time
}
/**
* One editable guest: the user controls the [email] (the identity we dedup and
* write on), an optional display [name], and whether they're [optional] rather
@@ -145,16 +158,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))
FormTime(LocalDateTime(startDate, LocalTime(9, 0))) to FormTime(LocalDateTime(endDate, LocalTime(10, 0)))
} else {
Instant.fromEpochMilliseconds(beginMillis).toLocalDateTime(formZone) to
Instant.fromEpochMilliseconds(endMillis).toLocalDateTime(formZone)
begin.toFormTime(formZone) to finish.toFormTime(formZone)
}
return EventForm(
calendarId = instance.calendarId,
@@ -221,20 +233,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<LocalDateTime, LocalDateTime>? {
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 +264,19 @@ fun EventForm.populatedFields(): Set<EventFormField> = buildSet {
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)
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.
@@ -3,10 +3,19 @@ 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.toJavaLocalDateTime
import kotlinx.datetime.toJavaZoneId
import kotlinx.datetime.toJavaZoneOffset
import kotlinx.datetime.toKotlinUtcOffset
import kotlinx.datetime.toLocalDateTime
import java.time.ZonedDateTime
import kotlin.time.Instant
import kotlin.time.toJavaInstant
import kotlin.time.toKotlinInstant
/**
* The zone this form's wall-clock times mean, matching what the data layer
@@ -15,6 +24,114 @@ 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 =
ZonedDateTime.ofLocal(toJavaLocalDateTime(), zone.toJavaZoneId(), preferred?.toJavaZoneOffset())
.toInstant().toKotlinInstant()
/** 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.getTransition(java.time.LocalDateTime.ofInstant(toJavaInstant(), zid))?.isOverlap == true
}
/**
* [at]'s offset in this zone when its wall clock is one the fall-back hour
* repeats, else null: elsewhere the wall clock alone already names the instant.
* What a [FormTime.offset] holds (#387).
*/
fun TimeZone.repeatedHourOffsetAt(at: Instant): UtcOffset? = if (at.inRepeatedHour(this)) offsetAt(at) else null
/** This instant as a [FormTime] in [zone], keeping its pass of a repeated hour. */
fun Instant.toFormTime(zone: TimeZone): FormTime = FormTime(toLocalDateTime(zone), zone.repeatedHourOffsetAt(this))
/** This time as an instant in [zone], on its kept pass of a repeated hour. */
fun FormTime.instantIn(zone: TimeZone): Instant = dateTime.resolveIn(zone, offset)
/**
* This edge of an event moved to [picked] by hand — a picker or a resize handle —
* as a [FormTime] in [zone]. A time in the spring-forward gap moves on to the
* real one, as the provider would read it. A wall clock the fall-back hour
* repeats has two instants; which one it means (#387):
* - [original]'s, when [picked] is the wall clock the event was opened with, so
* a time changed and changed back is the time it was;
* - else the one that moves the edge in the direction the user moved it:
* earlier than this edge's instant when [picked] is before [shownAt] (where
* the edge was on screen), later when after;
* - kept within [notBefore]..[notAfter] (the other edge), and to the ones
* [drawable] accepts — where the screen can show it — when any fit.
*/
fun FormTime.repicked(
picked: LocalDateTime,
zone: TimeZone,
shownAt: LocalDateTime = dateTime,
original: FormTime? = null,
notBefore: Instant? = null,
notAfter: Instant? = null,
drawable: (Instant) -> Boolean = { true },
): FormTime {
if (picked == dateTime && picked == shownAt) return this
val offsets = zone.toJavaZoneId().rules.getValidOffsets(picked.toJavaLocalDateTime())
if (offsets.isEmpty()) return FormTime(picked).instantIn(zone).toFormTime(zone)
if (offsets.size == 1) return FormTime(picked)
val passes = offsets.map { picked.toInstant(it.toKotlinUtcOffset()) }.sorted()
val bounded = passes.filter { (notBefore == null || it >= notBefore) && (notAfter == null || it <= notAfter) }
.ifEmpty { passes }
val fitting = bounded.filter(drawable).ifEmpty { bounded }
if (original?.dateTime == picked && original.offset != null && original.instantIn(zone) in fitting) return original
val from = instantIn(zone)
val chosen = when {
picked < shownAt -> fitting.lastOrNull { it < from }
picked > shownAt -> fitting.firstOrNull { it > from }
else -> null
} ?: fitting.minBy { (it - from).absoluteValue }
return chosen.toFormTime(zone)
}
/** The form's start as an instant in [zone]. */
fun EventForm.startInstant(zone: TimeZone): Instant = start.instantIn(zone)
/** The form's end as an instant in [zone]. */
fun EventForm.endInstant(zone: TimeZone): Instant = end.instantIn(zone)
/** The form's timed span set to these instants, read in [zone]. */
fun EventForm.withTimes(start: Instant, end: Instant, zone: TimeZone): EventForm =
copy(start = start.toFormTime(zone), end = end.toFormTime(zone))
/**
* The form pinned to [zoneId] (null: the device). A kept pass of a repeated hour
* was one of the old zone's, so it goes when the zone the times mean changes.
*/
fun EventForm.withTimezone(zoneId: String?, deviceZone: TimeZone): EventForm {
val pinned = copy(timezone = zoneId)
if (pinned.resolvedZone(deviceZone) == resolvedZone(deviceZone)) return pinned
return pinned.withoutKeptPasses()
}
/**
* The form with its start picked by hand, keeping its length. A repeated hour's
* pass is [repicked]: [original] is the start the event was opened with.
*/
fun EventForm.withStart(start: LocalDateTime, zone: TimeZone, original: FormTime? = null): EventForm {
val span = endInstant(zone) - startInstant(zone)
val startAt = this.start.repicked(start, zone, original = original).instantIn(zone)
return withTimes(startAt, startAt + span, zone)
}
/**
* The form with its end picked by hand. A repeated hour's pass is [repicked],
* kept after the start where it can be: [original] is the end the event was
* opened with.
*/
fun EventForm.withEnd(end: LocalDateTime, zone: TimeZone, original: FormTime? = null): EventForm =
copy(end = this.end.repicked(end, zone, original = original, notBefore = startInstant(zone)))
/** The form with no kept passes: its times as the wall clock alone says them. */
fun EventForm.withoutKeptPasses(): EventForm = copy(start = FormTime(start.dateTime), end = FormTime(end.dateTime))
/**
* 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 +143,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)
}
/**
@@ -43,21 +157,21 @@ fun EventForm.shiftedByDays(days: Int, deviceZone: TimeZone): EventForm {
val newStart = LocalDateTime(start.date.plus(days, DateTimeUnit.DAY), start.time)
if (isAllDay) {
return copy(
start = newStart,
end = LocalDateTime(end.date.plus(days, DateTimeUnit.DAY), end.time),
start = FormTime(newStart),
end = FormTime(LocalDateTime(end.date.plus(days, DateTimeUnit.DAY), end.time)),
)
}
val zone = resolvedZone(deviceZone)
val span = 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 = FormTime(newStart, start.offset).instantIn(zone)
// The start keeps the wall clock it was moved to, even one the spring-forward
// gap skips, so a recurring series still sees a whole-day move.
val movedStart = startAt.toFormTime(zone).takeIf { it.dateTime == newStart } ?: FormTime(newStart)
return copy(start = movedStart, end = (startAt + span).toFormTime(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))
}
@@ -4,7 +4,6 @@ import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Duration.Companion.hours
import kotlin.time.Instant
@@ -46,16 +45,12 @@ fun buildInsertEventForm(
?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.UTC).date }
?.let { exclusive -> maxOf(startDate, LocalDate.fromEpochDays(exclusive.toEpochDays() - 1)) }
?: startDate
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
FormTime(LocalDateTime(startDate, LocalTime(9, 0))) to FormTime(LocalDateTime(endDate, LocalTime(10, 0)))
} else {
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
val startAt = beginMillis?.let(Instant::fromEpochMilliseconds) ?: nextFullHour(now)
val endAt = endMillis?.let(Instant::fromEpochMilliseconds)?.takeIf { it >= startAt }
?: (startAt + 1.hours)
startAt.toFormTime(zone) to endAt.toFormTime(zone)
}
return EventForm(
calendarId = null,
@@ -71,8 +66,8 @@ fun buildInsertEventForm(
)
}
private fun nextFullHour(now: Instant, zone: TimeZone): LocalDateTime {
/** The first full hour after [now]: may roll into the next day before midnight. */
internal fun nextFullHour(now: Instant): Instant {
val hourMillis = 3_600_000L
val rounded = (now.toEpochMilliseconds() / hourMillis + 1) * hourMillis
return Instant.fromEpochMilliseconds(rounded).toLocalDateTime(zone)
return Instant.fromEpochMilliseconds((now.toEpochMilliseconds() / hourMillis + 1) * hourMillis)
}
@@ -157,14 +157,36 @@ fun timeZoneOptionOf(
* the offset isn't stated twice in ICU's spelling and ours.
*/
fun zoneDescriptor(option: TimeZoneOption): String {
val abbrev = option.shortName
return when {
abbrev == "UTC" -> "UTC"
abbrev.startsWith("GMT") -> formatGmtOffset(option.offsetMinutes)
else -> "$abbrev · ${formatGmtOffset(option.offsetMinutes)}"
}
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 = abbreviation(option.shortName, option.offsetMinutes)
/**
* Zone [id]'s abbreviation [at] an instant, as [zoneAbbreviation] spells it,
* without the localized long name a whole [TimeZoneOption] would look up. Null
* for an id the tz database can't parse.
*/
fun zoneAbbreviationAt(
id: String,
locale: Locale,
at: Instant,
regionOf: (String) -> String? = { null },
): String? {
val zone = runCatching { ZoneId.of(id) }.getOrNull() ?: return null
val shortName = resolveAbbreviation(zone.id, zone.rules.isDaylightSavings(at), locale, regionOf)
return abbreviation(shortName, zone.rules.getOffset(at).totalSeconds / 60)
}
private fun abbreviation(shortName: String, offsetMinutes: Int): String =
if (shortName.startsWith("GMT")) formatGmtOffset(offsetMinutes) else shortName
/**
* [options] matching [query], best matches first; a blank query returns the list
* unchanged. Matching is accent- and case-insensitive and treats underscores as
@@ -1,6 +1,8 @@
package de.jeanlucmakiola.calendula.domain.ics
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.FormTime
import de.jeanlucmakiola.calendula.domain.toFormTime
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
@@ -24,9 +26,9 @@ fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm {
val startDate = this.start.toLocalDateTime(TimeZone.UTC).date
val endExclusive = this.end.toLocalDateTime(TimeZone.UTC).date
val endDate = maxOf(startDate, LocalDate.fromEpochDays(endExclusive.toEpochDays() - 1))
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
FormTime(LocalDateTime(startDate, LocalTime(9, 0))) to FormTime(LocalDateTime(endDate, LocalTime(10, 0)))
} else {
this.start.toLocalDateTime(zone) to this.end.toLocalDateTime(zone)
this.start.toFormTime(zone) to this.end.toFormTime(zone)
}
return EventForm(
calendarId = null,
@@ -96,6 +96,7 @@ import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Clock
import kotlin.time.Instant
/**
* Holds the top-level view back stack (spec M1) and swaps between the calendar
@@ -240,18 +241,24 @@ fun CalendarHost(
var heldCreateIso by remember { mutableStateOf<String?>(null) }
var createStartMinutes by rememberSaveable { mutableStateOf<Int?>(null) }
var heldCreateMinutes by remember { mutableStateOf<Int?>(null) }
// A drafted block's end (#153), in minutes from the start day's midnight.
var createEndMinutes by rememberSaveable { mutableStateOf<Int?>(null) }
var heldCreateEndMinutes by remember { mutableStateOf<Int?>(null) }
val onCreateSpan: (LocalDate, Int?, Int?) -> Unit = { date, startMinutes, endMinutes ->
// A drafted block's start and end (#153) as epoch millis. Instants rather than
// minutes into a day, so the form opens at the times drawn even across a DST
// change (#387).
var createDraftMillis by rememberSaveable { mutableStateOf<LongArray?>(null) }
var heldCreateDraftMillis by remember { mutableStateOf<LongArray?>(null) }
fun openCreate(date: LocalDate, startMinutes: Int?, draftMillis: LongArray?) {
heldCreateIso = date.toString()
createDateIso = date.toString()
heldCreateMinutes = startMinutes
createStartMinutes = startMinutes
heldCreateEndMinutes = endMinutes
createEndMinutes = endMinutes
heldCreateDraftMillis = draftMillis
createDraftMillis = draftMillis
}
val onCreateEvent: (LocalDate, Int?) -> Unit = { date, startMinutes -> openCreate(date, startMinutes, null) }
val onCreateDraft: (Instant, Instant) -> Unit = { start, end ->
val date = start.toLocalDateTime(TimeZone.currentSystemDefault()).date
openCreate(date, null, longArrayOf(start.toEpochMilliseconds(), end.toEpochMilliseconds()))
}
val onCreateEvent: (LocalDate, Int?) -> Unit = { date, startMinutes -> onCreateSpan(date, startMinutes, null) }
// Edit form (v1.3) — reuses the detail screen's occurrence key; for
// recurring events the form itself asks for the write scope at save
@@ -387,8 +394,8 @@ fun CalendarHost(
createDateIso = iso
heldCreateMinutes = null
createStartMinutes = null
heldCreateEndMinutes = null
createEndMinutes = null
heldCreateDraftMillis = null
createDraftMillis = null
onWidgetNavConsumed()
}
null -> {}
@@ -604,7 +611,7 @@ fun CalendarHost(
onOpenDay = onOpenDay,
onEventClick = onEventClick,
onCreateEvent = onCreateEvent,
onCreateSpan = onCreateSpan,
onCreateDraft = onCreateDraft,
)
ViewScreen.Month -> MonthScreen(
onOpenDay = onOpenDay,
@@ -693,7 +700,7 @@ fun CalendarHost(
EventEditScreen(
initialDateIso = iso,
initialStartMinutes = createStartMinutes ?: heldCreateMinutes,
initialEndMinutes = createEndMinutes ?: heldCreateEndMinutes,
initialDraft = createDraftMillis ?: heldCreateDraftMillis,
onClose = { createDateIso = null },
onSaved = { createDateIso = null },
onManageCalendars = { showCalendars = true },
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.ui.common
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.time.ZoneSource
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
@@ -16,6 +17,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
@@ -128,6 +131,7 @@ sealed interface MoveOutcome {
class RescheduleViewModel @Inject constructor(
private val repository: CalendarRepository,
@IoDispatcher private val io: CoroutineDispatcher,
private val zoneSource: ZoneSource,
) : ViewModel() {
private val _scopePrompt = MutableStateFlow<MoveScopePrompt?>(null)
@@ -317,7 +321,7 @@ class RescheduleViewModel @Inject constructor(
return null
}
val zone = TimeZone.currentSystemDefault()
val zone = zoneSource.current()
val original = detail.toEditForm(request.beginMillis, request.endMillis, zone)
val shifted = when (val target = request.target) {
is MoveTarget.Start -> original.shiftedTo(target.instant, zone)
@@ -329,7 +333,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 +499,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(zoneSource.current())).toEpochMilliseconds()
}
/**
@@ -508,8 +511,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(zoneSource.current())).toEpochMilliseconds()
}
}
@@ -1,7 +1,12 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.staticCompositionLocalOf
import de.jeanlucmakiola.calendula.domain.inRepeatedHour
import de.jeanlucmakiola.calendula.domain.zoneAbbreviationAt
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 +73,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 abbreviation = zoneAbbreviationAt(zone.id, locale, instant.toJavaInstant(), ::icuTimeZoneRegion)
?: return formatted
return "$formatted $abbreviation"
}
@@ -46,8 +46,16 @@ import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.instantIn
import de.jeanlucmakiola.calendula.domain.repeatedHourOffsetAt
import de.jeanlucmakiola.calendula.domain.repicked
import de.jeanlucmakiola.calendula.domain.toFormTime
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.calendula.ui.week.wallClockMinutes
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.floret.locale.currentLocale
import kotlinx.coroutines.delay
@@ -55,13 +63,14 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.UtcOffset
import kotlinx.datetime.plus
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import kotlin.math.roundToInt
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Instant
@@ -297,12 +306,13 @@ internal fun draftStartMin(minutes: Float): Int =
/** The part of [draft] on [date] as a full-width block, or null if none of it falls there. */
internal fun draftBlock(draft: EventInstance, date: LocalDate, zone: TimeZone): TimedBlock? {
val start = draft.start.toLocalDateTime(zone)
val end = draft.end.toLocalDateTime(zone)
// Clipped to the day the way the grid lays out events, so it's drawn the same across a DST change (#387).
// The end is exclusive, so a draft ending at midnight belongs to the day before.
if (start.date > date || end.date < date || (end.date == date && end.time == LocalTime(0, 0))) return null
val startMin = if (start.date < date) 0 else start.time.toSecondOfDay() / 60
val endMin = if (end.date > date) MINUTES_PER_DAY else end.time.toSecondOfDay() / 60
val start = maxOf(draft.start, date.atStartOfDayIn(zone))
val end = minOf(draft.end, date.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone))
if (end <= start) return null
val startMin = wallClockMinutes(start, date, zone)
val endMin = wallClockEndMinutes(startMin, start, end, date, zone).coerceAtMost(MINUTES_PER_DAY)
return TimedBlock(draft, startMin, endMin, lane = 0, laneCount = 1)
}
@@ -312,6 +322,53 @@ enum class ResizeEdge { Top, Bottom }
/** Where a finished resize asks its event to begin and end. */
data class TimelineResizeDrop(val event: EventInstance, val start: Instant, val end: Instant)
/**
* [event] resized by its [edge] to the span [startMin]..+[spanMin] from [date]'s
* midnight. Only the held edge moves. The other keeps its own instant rather than
* a round trip through the grid, where an end in the fall-back hour's repeat is
* drawn to that hour's end, not at its own time (#387). In a repeated hour, the
* held edge takes the pass that matches the direction it was dragged from where
* it was drawn (see [repicked]). An end takes a pass the grid draws at the drop
* point, so the block settles under the finger.
*/
internal fun resizedDrop(
event: EventInstance,
edge: ResizeEdge?,
date: LocalDate,
startMin: Int,
spanMin: Int,
zone: TimeZone,
): TimelineResizeDrop {
val drawn = wallClockSpan(event, zone)
return TimelineResizeDrop(
event = event,
start = if (edge == ResizeEdge.Top) {
event.start.toFormTime(zone).repicked(
wallClockTime(date, startMin),
zone,
shownAt = wallClockTime(drawn.day, drawn.startMin),
notAfter = event.end,
).instantIn(zone)
} else {
event.start
},
end = if (edge == ResizeEdge.Bottom) {
val picked = wallClockTime(date, startMin + spanMin)
val pickedMin = (picked.date.toEpochDays() - drawn.day.toEpochDays()).toInt() * MINUTES_PER_DAY +
picked.time.toSecondOfDay() / 60
event.end.toFormTime(zone).repicked(
picked,
zone,
shownAt = wallClockTime(drawn.day, drawn.endMin),
notBefore = event.start,
drawable = { wallClockEndMinutes(drawn.startMin, event.start, it, drawn.day, zone) == pickedMin },
).instantIn(zone)
} else {
event.end
},
)
}
/**
* The span after dragging [edge] to [edgeMin] in day [edgeDay], in minutes from the
* start day's midnight. Snapped, and at least one snap step long.
@@ -547,6 +604,10 @@ 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
/** The dragged event's pass of a repeated hour, worked out once per drag (#387). */
private var dragKept: UtcOffset? = null
/** What the picked-up block drew its title over — see [TimelineDrag.titleLines]. */
private var titleLines = 0
@@ -580,6 +641,7 @@ class TimelineDragController {
fun begin(
block: TimedBlock,
clipOffsetMin: Int,
zone: TimeZone,
titleLines: Int,
pointerInRoot: Offset,
blockInRoot: Offset,
@@ -588,6 +650,9 @@ class TimelineDragController {
if (!isSelected(block)) deselect()
source = block
this.clipOffsetMin = clipOffsetMin
dragZone = zone
dragDuration = (block.event.end - block.event.start).coerceAtLeast(Duration.ZERO)
dragKept = zone.repeatedHourOffsetAt(block.event.start)
this.titleLines = titleLines
settling = null
isDragging = true
@@ -605,10 +670,10 @@ class TimelineDragController {
* Draft a block on an empty slot and lift it at once (#153). It is selected
* from the start, so it keeps its handles and bar once the finger lets go.
*/
fun beginDraft(block: TimedBlock, pointerInRoot: Offset, blockInRoot: Offset) {
fun beginDraft(block: TimedBlock, zone: TimeZone, pointerInRoot: Offset, blockInRoot: Offset) {
deselect()
select(block.event)
begin(block, clipOffsetMin = 0, titleLines = 1, pointerInRoot = pointerInRoot, blockInRoot = blockInRoot)
begin(block, clipOffsetMin = 0, zone, titleLines = 1, pointerInRoot = pointerInRoot, blockInRoot = blockInRoot)
}
fun move(pointerInRoot: Offset) {
@@ -624,6 +689,9 @@ class TimelineDragController {
resizeSlot = null
source = null
clipOffsetMin = 0
dragZone = null
dragDuration = Duration.ZERO
dragKept = null
titleLines = 0
isDragging = false
pointerMoved = false
@@ -748,13 +816,10 @@ 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
val drop = TimelineResizeDrop(
event = landed.event,
start = wallClockInstant(landed.date, landed.eventStartMin, zone),
end = wallClockInstant(landed.date, landed.eventStartMin + landed.eventSpanMin, zone),
)
val drop = resizedDrop(landed.event, edge, landed.date, landed.eventStartMin, landed.eventSpanMin, zone)
settlingSelected = selection?.event?.instanceId == landed.event.instanceId
settling = if (landed.edgeOnly) landed.copy(pieces = emptyList()) else landed
liftedInstanceId = landed.event.instanceId
@@ -882,13 +947,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 = dragKept
// Measured where it would land: the drop keeps the real duration, which in
// wall clock gains or loses the hour of a DST change it crosses (#387).
val floor = dragFloorMin(
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 {
@@ -976,10 +1050,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)
}
/**
@@ -1001,11 +1072,34 @@ 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, kept = zone.repeatedHourOffsetAt(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 =
wallClockTime(date, minutes).toInstant(zone)
/**
* 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,
kept: UtcOffset? = null,
): Int {
val start = wallClockInstant(date, startMin, zone, kept)
return wallClockEndMinutes(startMin, start, start + duration, date, zone) - startMin
}
/**
* [minutes] past [date]'s midnight, either way past it, as wall clock in [zone]
* (DST-safe). A time the fall-back hour repeats lands on its earlier pass, unless
* [kept] is its later one: an event there stays on it (#387). Pass
* [repeatedHourOffsetAt] of the event's time, which is null outside the hour —
* such an instant says nothing about which pass the grid slot means, so a drop
* never depends on the season the event came from.
*/
fun wallClockInstant(date: LocalDate, minutes: Int, zone: TimeZone, kept: UtcOffset? = null): Instant =
wallClockTime(date, minutes).resolveIn(zone, kept)
/** The wall-clock time [minutes] past [date]'s midnight, on a later day past 1440. */
fun wallClockTime(date: LocalDate, minutes: Int): LocalDateTime {
@@ -1017,14 +1111,18 @@ fun wallClockTime(date: LocalDate, minutes: Int): LocalDateTime {
/** An event's span as wall-clock minutes from its start day's midnight. */
data class WallClockSpan(val day: LocalDate, val startMin: Int, val endMin: Int)
/** [event]'s [WallClockSpan], its end past 1440 when it ends on a later day (DST-safe). */
/**
* [event]'s [WallClockSpan], its end past 1440 when it ends on a later day
* (DST-safe). One ending in the fall-back hour's repeat ends where that hour does.
*/
fun wallClockSpan(event: EventInstance, zone: TimeZone): WallClockSpan {
val start = event.start.toLocalDateTime(zone)
val end = event.end.toLocalDateTime(zone)
val startMin = start.time.toSecondOfDay() / 60
val endMin = (end.date.toEpochDays() - start.date.toEpochDays()).toInt() * MINUTES_PER_DAY +
end.time.toSecondOfDay() / 60
return WallClockSpan(start.date, startMin, maxOf(startMin, endMin))
return WallClockSpan(
start.date,
startMin,
wallClockEndMinutes(startMin, event.start, event.end, start.date, zone),
)
}
/**
@@ -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
@@ -377,7 +378,7 @@ private fun EventDetailContent(
val gap = 12.dp
// "When" — date/all-day plus the time range.
val (whenPrimary, whenSecondary) = formatWhen(instance, TimeZone.currentSystemDefault(), locale)
val (whenPrimary, whenSecondary) = rememberWhen(instance, TimeZone.currentSystemDefault(), locale)
DetailCard(icon = Icons.Default.Schedule, iconContentDescription = null) {
Text(text = whenPrimary, style = MaterialTheme.typography.titleMedium)
if (whenSecondary != null) {
@@ -417,7 +418,7 @@ private fun EventDetailContent(
// in the primary instead, so fall back to it. If the zone can't be
// read back at all, drop to just the abbreviation + offset.
val originalTime = runCatching { TimeZone.of(zoneOption.id) }.getOrNull()
?.let { formatWhen(instance, it, locale) }
?.let { rememberWhen(instance, it, locale) }
?.let { (primary, secondary) -> secondary ?: primary }
Spacer(Modifier.height(2.dp))
Text(
@@ -806,18 +807,28 @@ private fun linkify(
* single primary line spanning both ends.
*/
@Composable
private fun rememberWhen(instance: EventInstance, zone: TimeZone, locale: Locale): Pair<String, String?> {
val use24Hour = LocalUse24HourFormat.current
val allDayLabel = stringResource(R.string.event_detail_all_day)
// Remembered: a time in the repeated hour looks up its zone abbreviation (#387).
return remember(instance, zone, locale, use24Hour, allDayLabel) {
formatWhen(instance, zone, locale, use24Hour, allDayLabel)
}
}
private fun formatWhen(
instance: EventInstance,
zone: TimeZone,
locale: Locale,
use24Hour: Boolean,
allDayLabel: String,
): Pair<String, String?> {
val zid = ZoneId.of(zone.id)
val dateFull = DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).withLocale(locale)
val dateMedium = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
val timeShort = timeOfDayFormatter(LocalUse24HourFormat.current, locale)
val timeShort = timeOfDayFormatter(use24Hour, locale)
val startLdt = instance.start.toJavaLocalDateTime(zid)
val allDayLabel = stringResource(R.string.event_detail_all_day)
if (instance.isAllDay) {
// All-day events live at UTC midnights with an exclusive end. Resolve
@@ -837,12 +848,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
}
}
@@ -125,10 +125,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
@@ -171,6 +174,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
@@ -183,7 +187,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
@@ -193,6 +196,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
@@ -214,7 +218,7 @@ fun EventEditScreen(
onSaved: () -> Unit,
editKey: LongArray? = null,
initialStartMinutes: Int? = null,
initialEndMinutes: Int? = null,
initialDraft: LongArray? = null,
initialForm: EventForm? = null,
initialFormSource: ImportSource = ImportSource.File,
onManageCalendars: (() -> Unit)? = null,
@@ -224,7 +228,9 @@ fun EventEditScreen(
visible: Boolean = true,
viewModel: EventEditViewModel = hiltViewModel(),
) {
LaunchedEffect(initialDateIso, editKey, initialForm) {
// Keyed on every input that decides the form, so a second draft or slot on
// the same day opens its own form even when the composition is kept alive.
LaunchedEffect(initialDateIso, editKey, initialForm, initialDraft, initialStartMinutes) {
when {
// A prefilled open: a single-event .ics for review, or an external
// ACTION_INSERT intent. The source drives how reminders are seeded.
@@ -234,10 +240,15 @@ fun EventEditScreen(
beginMillis = editKey[1],
endMillis = editKey[2],
)
// A block drafted on the timeline (#153): its own start and end.
initialDraft != null -> viewModel.openDraft(
start = Instant.fromEpochMilliseconds(initialDraft[0]),
end = Instant.fromEpochMilliseconds(initialDraft[1]),
)
else -> {
val date = initialDateIso?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
viewModel.openNew(date, initialStartMinutes, initialEndMinutes)
?: Clock.System.now().toLocalDateTime(viewModel.zone.value).date
viewModel.openNew(date, initialStartMinutes)
}
}
}
@@ -681,6 +692,7 @@ private fun EventEditContent(
) {
val form = state.form
val locale = currentLocale()
val deviceZone by viewModel.zone.collectAsStateWithLifecycle()
val firstDayOfWeek by viewModel.firstDayOfWeek.collectAsStateWithLifecycle()
val dark = isSystemInDarkTheme()
// The title, date and recurrence are managed by the special-dates sync, so
@@ -842,9 +854,14 @@ private fun EventEditContent(
},
) {
Spacer(Modifier.height(4.dp))
val formZone = remember(form.timezone, deviceZone) { form.resolvedZone(deviceZone) }
val startAt = remember(form.start, formZone) { form.startInstant(formZone) }
val endAt = remember(form.end, formZone) { form.endInstant(formZone) }
ScheduleRow(
label = stringResource(R.string.event_edit_starts),
dateTime = form.start,
dateTime = form.start.dateTime,
instant = startAt,
zone = formZone,
isAllDay = form.isAllDay,
locale = locale,
onDateClick = { picker = PickerTarget.StartDate },
@@ -853,7 +870,9 @@ private fun EventEditContent(
)
ScheduleRow(
label = stringResource(R.string.event_edit_ends),
dateTime = form.end,
dateTime = form.end.dateTime,
instant = endAt,
zone = formZone,
isAllDay = form.isAllDay,
locale = locale,
onDateClick = { picker = PickerTarget.EndDate },
@@ -872,20 +891,20 @@ 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.
// leaving the user to do the offset arithmetic. Absent 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())
val pinnedAway = remember(form.timezone, form.isAllDay, deviceZone) {
form.isPinnedAwayFrom(deviceZone)
}
localTimes?.let { (localStart, localEnd) ->
if (pinnedAway) {
Spacer(Modifier.height(2.dp))
Text(
text = stringResource(
R.string.event_edit_timezone_local_time,
formatTimeRange(localStart, localEnd, locale),
formatTimeRange(startAt, endAt, deviceZone, locale),
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -949,7 +968,7 @@ private fun EventEditContent(
timeZoneOptionOf(
id,
locale,
at = form.start.toInstant(zone).toJavaInstant(),
at = form.startInstant(zone).toJavaInstant(),
regionOf = ::icuTimeZoneRegion,
)
}
@@ -2431,18 +2450,23 @@ 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"
return remember(start, end, zone, locale, use24Hour) {
val timeFormat = timeOfDayFormatter(use24Hour, locale)
fun format(at: Instant) = withRepeatedHourZone(
timeFormat.format(at.toLocalDateTime(zone).time.toJavaLocalTime()),
at,
zone,
locale,
)
if (start == end) format(start) else "${format(start)} – ${format(end)}"
}
}
/**
@@ -2524,6 +2548,8 @@ private fun firstLineHeight(
private fun ScheduleRow(
label: String,
dateTime: LocalDateTime,
instant: Instant,
zone: TimeZone,
isAllDay: Boolean,
locale: Locale,
onDateClick: () -> Unit,
@@ -2565,8 +2591,11 @@ private fun ScheduleRow(
.padding(vertical = 8.dp, horizontal = 6.dp),
)
if (!isAllDay) {
val time = remember(dateTime, instant, zone, locale, timeFormat) {
withRepeatedHourZone(timeFormat.format(dateTime.time.toJavaLocalTime()), instant, zone, locale)
}
Text(
text = timeFormat.format(dateTime.time.toJavaLocalTime()),
text = time,
style = MaterialTheme.typography.titleMedium,
color = valueColor,
modifier = Modifier
@@ -11,6 +11,7 @@ import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.time.ZoneSource
import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultEventDuration
import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultReminder
@@ -22,6 +23,15 @@ import de.jeanlucmakiola.calendula.domain.EventAttendee
import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.endInstant
import de.jeanlucmakiola.calendula.domain.nextFullHour
import de.jeanlucmakiola.calendula.domain.toFormTime
import de.jeanlucmakiola.calendula.domain.resolvedZone
import de.jeanlucmakiola.calendula.domain.startInstant
import de.jeanlucmakiola.calendula.domain.withEnd
import de.jeanlucmakiola.calendula.domain.withStart
import de.jeanlucmakiola.calendula.domain.withTimes
import de.jeanlucmakiola.calendula.domain.withTimezone
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.isEventTarget
@@ -32,7 +42,6 @@ import de.jeanlucmakiola.calendula.ui.detail.EventDetailViewModel.Companion.NO_O
import de.jeanlucmakiola.calendula.ui.common.DeleteOutcome
import de.jeanlucmakiola.calendula.ui.common.tryDeleteEvent
import de.jeanlucmakiola.calendula.ui.common.stageEventShare
import de.jeanlucmakiola.calendula.ui.common.wallClockTime
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
@@ -141,8 +150,13 @@ class EventEditViewModel @Inject constructor(
private val settingsPrefs: SettingsPrefs,
private val icsExporter: IcsExporter,
@IoDispatcher private val io: CoroutineDispatcher,
private val zoneSource: ZoneSource,
) : ViewModel() {
/** The zone the form's unpinned times are read in, for the screen to show them in too. */
val zone: StateFlow<TimeZone> = zoneSource.zone
.stateIn(viewModelScope, SharingStarted.Eagerly, zoneSource.current())
private val _form = MutableStateFlow<EventForm?>(null)
private val _saveState = MutableStateFlow<SaveUiState>(SaveUiState.Idle)
// Problems stay hidden until the first save attempt, so a half-filled
@@ -302,7 +316,11 @@ class EventEditViewModel @Inject constructor(
EventEditUiState(
form = resolved,
calendars = pickerCalendars,
problems = if (local.showProblems) resolved.problems() else emptySet(),
problems = if (local.showProblems) {
resolved.problems(zoneSource.current())
} else {
emptySet()
},
saveState = local.saveState,
visibleFields = visibleFields,
hiddenFields = (offerableFields - visibleFields).sorted(),
@@ -349,32 +367,37 @@ class EventEditViewModel @Inject constructor(
* in the day/week grid; without it the default is the next full hour (today)
* or 09:00 (any other day). The event opens an hour long and is stretched to
* the configured default by [applyDefaultDuration], like the reminders.
* A block drafted on the timeline (#153) passes [endMinutes] instead,
* counted from [date]'s midnight and past 1440 for a later day.
* No-op when a form is already open, so user input survives configuration
* changes; [reset] clears it when the screen closes.
*/
fun openNew(date: LocalDate, startMinutes: Int? = null, endMinutes: Int? = null) {
fun openNew(date: LocalDate, startMinutes: Int? = null) {
if (_form.value != null) return
val zone = TimeZone.currentSystemDefault()
val zone = zoneSource.current()
val now = Clock.System.now()
val start = when {
val startAt = when {
startMinutes != null ->
LocalDateTime(date, LocalTime(startMinutes / 60, startMinutes % 60))
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)
}
else -> LocalDateTime(date, LocalTime(9, 0))
LocalDateTime(date, LocalTime(startMinutes / 60, startMinutes % 60)).toInstant(zone)
date == now.toLocalDateTime(zone).date -> nextFullHour(now)
else -> LocalDateTime(date, LocalTime(9, 0)).toInstant(zone)
}
val chosenEnd = endMinutes?.takeIf { startMinutes != null && it > startMinutes }
?.let { wallClockTime(date, it) }
val end = chosenEnd ?: (start.toInstant(zone) + SettingsPrefs.DEFAULT_EVENT_DURATION.minutes)
.toLocalDateTime(zone)
_form.value = EventForm(calendarId = null, start = start, end = end)
if (chosenEnd != null) _durationTouched.value = true
val endAt = startAt + SettingsPrefs.DEFAULT_EVENT_DURATION.minutes
_form.value = EventForm(calendarId = null, start = startAt.toFormTime(zone), end = endAt.toFormTime(zone))
applyDefaultReminder()
applyDefaultDuration()
}
/**
* Initialise a fresh form for a block drafted on the timeline (#153), from
* [start] to [end]. These are the instants the block shows, so a time in the
* fall-back hour's repeat, or one moved out of the spring-forward gap, opens
* as drawn (#387). The user chose the length, so the default length doesn't
* apply. No-op when a form is already open, as [openNew].
*/
fun openDraft(start: Instant, end: Instant) {
if (_form.value != null) return
val zone = zoneSource.current()
_form.value = EventForm(calendarId = null, start = start.toFormTime(zone), end = end.toFormTime(zone))
_durationTouched.value = true
applyDefaultReminder()
applyDefaultDuration()
}
@@ -484,11 +507,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(zoneSource.current())
val start = form.startInstant(zone)
form.withTimes(start, start + duration.minutes, zone)
}
}
}
@@ -549,7 +571,7 @@ class EventEditViewModel @Inject constructor(
_loadFailed.value = true
return@launch
}
val zone = TimeZone.currentSystemDefault()
val zone = zoneSource.current()
val begin = beginMillis.takeUnless { it == NO_OCCURRENCE_TIME }
?: detail.instance.start.toEpochMilliseconds()
val end = endMillis.takeUnless { it == NO_OCCURRENCE_TIME }
@@ -607,7 +629,11 @@ class EventEditViewModel @Inject constructor(
// the provider stores UTC-anchored, so a zone could only misrepresent
// them. Coming back out of all-day leaves the event on the device zone,
// which is the same thing a fresh event gets.
edit { it.copy(isAllDay = value, timezone = if (value) null else it.timezone) }
// A kept pass of a repeated hour stays, so switching back out of all-day
// lands where the event was — unless dropping the pin changed the zone.
edit {
if (value) it.withTimezone(null, zoneSource.current()).copy(isAllDay = true) else it.copy(isAllDay = false)
}
// The default reminder differs for all-day vs timed; re-apply the
// type-appropriate default unless the user has hand-edited it (guarded).
applyDefaultReminder()
@@ -622,7 +648,7 @@ class EventEditViewModel @Inject constructor(
* time — only real picks are remembered, not the device fallback.
*/
fun setTimezone(zoneId: String?) {
edit { it.copy(timezone = zoneId) }
edit { it.withTimezone(zoneId, zoneSource.current()) }
if (zoneId != null) {
viewModelScope.launch { withContext(io) { settingsPrefs.addRecentTimeZone(zoneId) } }
}
@@ -700,12 +726,12 @@ class EventEditViewModel @Inject constructor(
// default duration, so a later calendar switch keeps their span (#54).
fun setEndDate(date: LocalDate) {
_durationTouched.value = true
edit { it.copy(end = LocalDateTime(date, it.end.time)) }
moveEnd { LocalDateTime(date, it.time) }
}
fun setEndTime(time: LocalTime) {
_durationTouched.value = true
edit { it.copy(end = LocalDateTime(it.end.date, time)) }
moveEnd { LocalDateTime(it.date, time) }
}
/**
@@ -718,7 +744,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(zoneSource.current()).isNotEmpty()) {
_showProblems.value = true
return
}
@@ -874,13 +900,20 @@ class EventEditViewModel @Inject constructor(
}
private fun moveStart(transform: (LocalDateTime) -> LocalDateTime) = edit { 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)
form.withStart(transform(form.start.dateTime), form.resolvedZone(zoneSource.current()), opened(form)?.start)
}
private fun moveEnd(transform: (LocalDateTime) -> LocalDateTime) = edit { form ->
form.withEnd(transform(form.end.dateTime), form.resolvedZone(zoneSource.current()), opened(form)?.end)
}
/**
* The event as it was opened, while its times still mean the same instants:
* picking one of them again keeps its pass of a repeated hour (#387).
*/
private fun opened(form: EventForm): EventForm? =
_editTarget.value?.original?.takeIf { it.timezone == form.timezone && !it.isAllDay }
private inline fun update(block: (EventForm) -> EventForm) {
_form.value = _form.value?.let(block)
}
@@ -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
@@ -73,7 +74,6 @@ import de.jeanlucmakiola.calendula.ui.common.rememberDragSurface
import de.jeanlucmakiola.calendula.ui.common.rememberWriteGate
import de.jeanlucmakiola.calendula.ui.common.OutOfViewEdges
import de.jeanlucmakiola.calendula.ui.common.wallClockInstant
import de.jeanlucmakiola.calendula.ui.common.wallClockSpan
import de.jeanlucmakiola.calendula.ui.common.PickUp
import de.jeanlucmakiola.calendula.ui.common.SelectionBarClearance
import de.jeanlucmakiola.calendula.ui.common.ResizeEdge
@@ -100,6 +100,7 @@ import androidx.compose.runtime.SideEffect
import de.jeanlucmakiola.calendula.ui.common.laneSlot
import de.jeanlucmakiola.calendula.ui.common.LaneColumn
import kotlin.math.roundToInt
import kotlin.time.Instant
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.draw.alpha
@@ -249,7 +250,7 @@ fun TimelineScreen(
onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
onCreateEvent: (LocalDate, Int?) -> Unit,
onCreateSpan: (LocalDate, Int, Int?) -> Unit,
onCreateDraft: (Instant, Instant) -> Unit,
modifier: Modifier = Modifier,
weekViewModel: WeekViewModel = hiltViewModel(),
multiDayViewModel: MultiDayViewModel = hiltViewModel(),
@@ -264,7 +265,7 @@ fun TimelineScreen(
onOpenDay = onOpenDay,
onEventClick = onEventClick,
onCreateEvent = onCreateEvent,
onCreateSpan = onCreateSpan,
onCreateDraft = onCreateDraft,
modifier = modifier,
)
}
@@ -311,6 +312,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
@@ -322,7 +329,7 @@ private fun PagedTimelineScreen(
onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
onCreateEvent: (LocalDate, Int?) -> Unit,
onCreateSpan: (LocalDate, Int, Int?) -> Unit,
onCreateDraft: (Instant, Instant) -> Unit,
modifier: Modifier,
) {
// The view on screen, which trails [view] while the columns resize into it.
@@ -449,7 +456,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 by viewModel.zone.collectAsStateWithLifecycle()
val today by remember(zone) { derivedStateOf { nowState.value.toLocalDateTime(zone).date } }
val isOnCurrentWeek = state !is WeekUiState.Success || (today >= weekStart && today <= weekEnd)
@@ -490,7 +498,10 @@ private fun PagedTimelineScreen(
)
ScreenBody(modifier = modifier) { innerPadding ->
CompositionLocalProvider(LocalDimCutoff provides dimCutoff) {
CompositionLocalProvider(
LocalDimCutoff provides dimCutoff,
LocalTimelineZone provides zone,
) {
WeekContent(
state = state,
today = today,
@@ -504,7 +515,7 @@ private fun PagedTimelineScreen(
onEventClick = onEventClick,
onOpenDay = onOpenDay,
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
onCreateSpan = onCreateSpan,
onCreateDraft = onCreateDraft,
draftMinutes = draftMinutes,
modifier = Modifier
.padding(innerPadding)
@@ -528,7 +539,7 @@ private fun WeekContent(
onEventClick: (EventInstance) -> Unit,
onOpenDay: (LocalDate) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onCreateSpan: (LocalDate, Int, Int?) -> Unit,
onCreateDraft: (Instant, Instant) -> Unit,
draftMinutes: Int,
modifier: Modifier = Modifier,
) {
@@ -546,7 +557,7 @@ private fun WeekContent(
val dragController = rememberTimelineDragController()
SideEffect { dragController.draftMinutes = draftMinutes }
val move = LocalEventMove.current
val zone = remember { TimeZone.currentSystemDefault() }
val zone = LocalTimelineZone.current
val onDrop: (TimelineDrop) -> Unit = drop@{ drop ->
val start = drop.startInstant(zone)
val moved = drop.event.copy(start = start, end = start + (drop.event.end - drop.event.start))
@@ -587,11 +598,11 @@ private fun WeekContent(
)
}
// A drafted block (#153) opens the new-event form at the span it shows.
// A drafted block (#153) opens the new-event form at its own instants: the
// end it is drawn to differs on a DST change (#387).
val createDraft: (EventInstance) -> Unit = { draft ->
dragController.deselect()
val span = wallClockSpan(draft, zone)
onCreateSpan(span.day, span.startMin, span.endMin)
onCreateDraft(draft.start, draft.end)
}
// Selection (#141) ends on back, a page or view change, or any tap.
@@ -1410,7 +1421,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
// A block drafted on an empty slot (#153), drawn over the part of it on this day.
val draft by remember(dragController) {
derivedStateOf { dragController.selection?.event?.takeIf { it.isDraft } }
@@ -1576,8 +1587,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
@@ -1591,7 +1604,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
@@ -1622,7 +1635,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 draft = block.event.isDraft
val draggable = draft || eventDragAllowed(block.event)
@@ -1644,6 +1656,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
@@ -1806,7 +1819,7 @@ private fun rememberDraftSurface(
val currentDrop by rememberUpdatedState(onDrop)
val title = stringResource(R.string.event_edit_new_title)
val color = MaterialTheme.colorScheme.primary.toArgb()
val zone = remember { TimeZone.currentSystemDefault() }
val zone = LocalTimelineZone.current
val currentEdges by rememberUpdatedState(edges)
// The draft this gesture lifted, selected again on release after the cancel.
val lifted = remember { arrayOfNulls<EventInstance>(1) }
@@ -1830,6 +1843,7 @@ private fun rememberDraftSurface(
lifted[0] = event
dragController.beginDraft(
block = block,
zone = zone,
pointerInRoot = pointer,
blockInRoot = node + Offset(0f, startMin / 60f * hour),
)
@@ -8,6 +8,7 @@ import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.data.time.TodaySource
import de.jeanlucmakiola.calendula.data.time.ZoneSource
import de.jeanlucmakiola.calendula.data.time.currentDate
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance
@@ -34,7 +35,10 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.FixedOffsetTimeZone
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toKotlinUtcOffset
import kotlinx.datetime.toJavaZoneId
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.atTime
import kotlinx.datetime.daysUntil
@@ -44,6 +48,8 @@ import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import java.util.Locale
import kotlin.time.Instant
import kotlin.time.toKotlinDuration
import kotlin.time.toKotlinInstant
import javax.inject.Inject
import kotlin.math.abs
@@ -76,12 +82,18 @@ abstract class PagedTimelineViewModel(
private val repository: CalendarRepository,
settingsPrefs: SettingsPrefs,
todaySource: TodaySource,
zoneSource: ZoneSource,
private val io: CoroutineDispatcher,
gridRule: Flow<(LocalDate) -> PageGrid>,
initialRule: (LocalDate) -> PageGrid,
) : ViewModel() {
private val zone = TimeZone.currentSystemDefault()
/**
* The zone every page is laid out in; drags and drops resolve in it too, and
* pages lay out again when the device moves zones (#387).
*/
val zone: StateFlow<TimeZone> = zoneSource.zone
.stateIn(viewModelScope, SharingStarted.Eagerly, zoneSource.current())
/** Whether to fade events that have already finished (display concern only). */
val dimCompletedEvents: StateFlow<Boolean> = settingsPrefs.dimCompletedEvents
@@ -160,12 +172,14 @@ abstract class PagedTimelineViewModel(
abs(cached.start.daysUntil(requested.start)) <= SPAN_CACHE_DAYS
},
) { span ->
combine(
repository.calendars(),
repository.instances(spanRange(span.start, span.count, zone)),
today,
) { calendars, instances, today ->
buildState(span, calendars, instances, today)
zone.flatMapLatest { zone ->
combine(
repository.calendars(),
repository.instances(spanRange(span.start, span.count, zone)),
today,
) { calendars, instances, today ->
buildState(span, calendars, instances, today, zone)
}
}
.catch { emit(WeekUiState.Failure(FailureReason.ProviderUnavailable)) }
.flowOn(io)
@@ -200,6 +214,7 @@ abstract class PagedTimelineViewModel(
calendars: List<CalendarSource>,
instances: List<EventInstance>,
today: LocalDate,
zone: TimeZone,
): WeekUiState {
calendarListFailure(calendars)?.let { return WeekUiState.Failure(it) }
val days = (0 until span.count).map { span.start.plus(it, DateTimeUnit.DAY) }
@@ -221,11 +236,13 @@ class WeekViewModel @Inject constructor(
repository: CalendarRepository,
settingsPrefs: SettingsPrefs,
todaySource: TodaySource,
zoneSource: ZoneSource,
@IoDispatcher io: CoroutineDispatcher,
) : PagedTimelineViewModel(
repository = repository,
settingsPrefs = settingsPrefs,
todaySource = todaySource,
zoneSource = zoneSource,
io = io,
gridRule = settingsPrefs.weekStart.map { pref ->
val grid = WeekGrid(pref.resolveFirstDay(Locale.getDefault()))
@@ -245,11 +262,13 @@ class MultiDayViewModel @Inject constructor(
repository: CalendarRepository,
settingsPrefs: SettingsPrefs,
todaySource: TodaySource,
zoneSource: ZoneSource,
@IoDispatcher io: CoroutineDispatcher,
) : PagedTimelineViewModel(
repository = repository,
settingsPrefs = settingsPrefs,
todaySource = todaySource,
zoneSource = zoneSource,
io = io,
gridRule = settingsPrefs.multiDayCount.map { count ->
{ anchor: LocalDate -> DaysGrid.startingAt(anchor, count) }
@@ -263,11 +282,13 @@ class DayViewModel @Inject constructor(
repository: CalendarRepository,
settingsPrefs: SettingsPrefs,
todaySource: TodaySource,
zoneSource: ZoneSource,
@IoDispatcher io: CoroutineDispatcher,
) : PagedTimelineViewModel(
repository = repository,
settingsPrefs = settingsPrefs,
todaySource = todaySource,
zoneSource = zoneSource,
io = io,
gridRule = flowOf { _: LocalDate -> DayGrid },
initialRule = { DayGrid },
@@ -339,6 +360,64 @@ 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. A run that began before the fall-back hour's repeat and ends
* inside it is drawn to where that hour ends (03:00 in Berlin), since its own
* wall-clock end reads earlier than its real length (#387).
*/
internal fun wallClockEndMinutes(
startMin: Int,
start: Instant,
end: Instant,
day: LocalDate,
zone: TimeZone,
): Int {
val wall = end.minutesPast(day, zone)
if (end <= start) return maxOf(wall, startMin)
val drawn = repeatedHourEndMinutes(start, end, day, zone) ?: wall
return if (drawn > startMin) drawn else startMin + (end - start).inWholeMinutes.toInt()
}
/**
* Where the fall-back hour's repeat [end] falls in ends, as minutes past [day]'s
* midnight: the wall clock the zone fell back from (03:00 in Berlin). Null unless
* [end] is in that repeat and [start] before it.
*/
private fun repeatedHourEndMinutes(start: Instant, 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 fellBack = transition.instant.toKotlinInstant()
if (start >= fellBack || end >= fellBack + transition.duration.abs().toKotlinDuration()) return null
// The moment it fell back, read on the clock it fell back from.
return fellBack.minutesPast(day, FixedOffsetTimeZone(transition.offsetBefore.toKotlinUtcOffset()))
}
/**
* 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) {
@@ -392,14 +471,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)
}
}
@@ -11,6 +11,7 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FormTime
import de.jeanlucmakiola.calendula.domain.SearchCandidate
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
@@ -510,8 +511,8 @@ class CalendarRepositoryImplTest {
val form = EventForm(
calendarId = 1L,
title = "Stand-up",
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 15)),
start = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 15))),
)
val id = repo.createEvent(form)
@@ -531,8 +532,8 @@ class CalendarRepositoryImplTest {
val form = EventForm(
calendarId = 1L,
isAllDay = true,
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(0, 0)),
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(0, 0)),
start = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(0, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(0, 0))),
)
repo.createEvent(form)
@@ -548,8 +549,8 @@ class CalendarRepositoryImplTest {
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
val form = EventForm(
calendarId = 1L,
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0)),
start = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0))),
)
try {
@@ -567,8 +568,8 @@ class CalendarRepositoryImplTest {
val original = EventForm(
calendarId = 1L,
title = "Stand-up",
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 15)),
start = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 15))),
)
val updated = original.copy(title = "Daily")
@@ -584,8 +585,8 @@ class CalendarRepositoryImplTest {
val original = EventForm(
calendarId = 1L,
title = "Stand-up",
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 15)),
start = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 15))),
)
val updated = original.copy(calendarId = 3L)
@@ -605,8 +606,8 @@ class CalendarRepositoryImplTest {
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
val form = EventForm(
calendarId = 1L,
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0)),
start = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0))),
)
try {
@@ -625,8 +626,8 @@ class CalendarRepositoryImplTest {
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
val form = EventForm(
calendarId = 1L,
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0)),
start = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0))),
)
try {
@@ -678,8 +679,8 @@ class CalendarRepositoryImplTest {
val form = EventForm(
calendarId = 1L,
title = "Moved",
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0)),
start = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0))),
)
val id = repo.updateOccurrence(
@@ -701,8 +702,8 @@ class CalendarRepositoryImplTest {
val original = EventForm(
calendarId = 1L,
title = "Weekly",
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0)),
start = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0))),
rrule = "FREQ=WEEKLY",
)
val updated = original.copy(title = "Weekly, renamed")
@@ -5,12 +5,15 @@ import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.AccessLevel
import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.FormTime
import de.jeanlucmakiola.calendula.domain.realignRecurrence
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.UtcOffset
import org.junit.jupiter.api.Test
import java.time.ZoneId
import kotlin.time.Instant
class EventWriteMapperTest {
@@ -24,8 +27,8 @@ class EventWriteMapperTest {
): EventForm = EventForm(
calendarId = 1L,
isAllDay = isAllDay,
start = start,
end = end,
start = FormTime(start),
end = FormTime(end),
timezone = timezone,
)
@@ -154,8 +157,8 @@ class EventWriteMapperTest {
assertThat(values).doesNotContainKey(CalendarContract.Events.EVENT_TIMEZONE)
val moved = original.copy(
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0)),
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 30)),
start = FormTime(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 30))),
)
assertThat(update(original, moved)[CalendarContract.Events.EVENT_TIMEZONE])
.isEqualTo("America/New_York")
@@ -201,8 +204,8 @@ class EventWriteMapperTest {
fun `time edit on a one-off event writes absolute times and clears recurrence columns`() {
val original = form()
val updated = original.copy(
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0)),
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 0)),
start = FormTime(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 0))),
)
val values = update(original, updated)
// 2026-06-11 11:00 CEST == 09:00Z.
@@ -221,8 +224,8 @@ class EventWriteMapperTest {
val original = form().copy(rrule = "FREQ=WEEKLY")
val updated = original.copy(
// Pushed one hour later than the displayed occurrence.
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0)),
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 30)),
start = FormTime(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 30))),
)
val values = update(original, updated)
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(seriesStart + 3_600_000L)
@@ -263,8 +266,8 @@ class EventWriteMapperTest {
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)),
).copy(rrule = "FREQ=WEEKLY")
val moved = original.copy(
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)),
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(11, 0)),
start = FormTime(LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 7, 15), LocalTime(11, 0))),
)
assertThat(update(original, moved, series)[CalendarContract.Events.DTSTART])
@@ -279,8 +282,8 @@ class EventWriteMapperTest {
end = LocalDateTime(LocalDate(2026, 1, 7), LocalTime(10, 0)),
).copy(rrule = "FREQ=WEEKLY")
val moved = original.copy(
start = LocalDateTime(LocalDate(2026, 1, 9), LocalTime(14, 30)),
end = LocalDateTime(LocalDate(2026, 1, 9), LocalTime(15, 30)),
start = FormTime(LocalDateTime(LocalDate(2026, 1, 9), LocalTime(14, 30))),
end = FormTime(LocalDateTime(LocalDate(2026, 1, 9), LocalTime(15, 30))),
)
assertThat(update(original, moved, series)[CalendarContract.Events.DTSTART])
@@ -297,8 +300,8 @@ class EventWriteMapperTest {
end = LocalDateTime(LocalDate(2026, 6, 8), LocalTime(10, 0)),
).copy(rrule = "FREQ=WEEKLY;BYDAY=MO")
val moved = original.copy(
start = LocalDateTime(LocalDate(2026, 6, 10), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 6, 10), LocalTime(10, 0)),
start = FormTime(LocalDateTime(LocalDate(2026, 6, 10), LocalTime(9, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 6, 10), LocalTime(10, 0))),
rrule = realignRecurrence("FREQ=WEEKLY;BYDAY=MO", LocalDate(2026, 6, 8), LocalDate(2026, 6, 10)),
)
@@ -319,8 +322,8 @@ class EventWriteMapperTest {
end = LocalDateTime(LocalDate(2026, 1, 7), LocalTime(10, 30)),
).copy(rrule = "FREQ=WEEKLY")
val moved = original.copy(
start = LocalDateTime(LocalDate(2026, 1, 8), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 1, 8), LocalTime(10, 30)),
start = FormTime(LocalDateTime(LocalDate(2026, 1, 8), LocalTime(9, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 1, 8), LocalTime(10, 30))),
)
assertThat(update(original, moved, series)[CalendarContract.Events.DURATION])
@@ -331,8 +334,8 @@ class EventWriteMapperTest {
fun `shifting a one-off event and back again restores the original values`() {
val original = form()
val moved = original.copy(
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(14, 0)),
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(15, 0)),
start = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(14, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(15, 0))),
)
val forward = update(original, moved)
@@ -357,8 +360,8 @@ class EventWriteMapperTest {
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)),
).copy(rrule = "FREQ=WEEKLY")
val moved = original.copy(
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(14, 30)),
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(15, 30)),
start = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(14, 30))),
end = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(15, 30))),
)
val movedAnchor = update(original, moved, series)[CalendarContract.Events.DTSTART] as Long
@@ -678,8 +681,8 @@ class EventWriteMapperTest {
/** [julySeries] pushed to [hour]:00, the shift an "all events" time edit makes. */
private fun EventForm.atHour(hour: Int): EventForm = copy(
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(hour, 0)),
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(hour + 1, 0)),
start = FormTime(LocalDateTime(LocalDate(2026, 7, 15), LocalTime(hour, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 7, 15), LocalTime(hour + 1, 0))),
)
@Test
@@ -728,8 +731,8 @@ class EventWriteMapperTest {
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
).copy(rrule = "FREQ=WEEKLY")
val moved = original.copy(
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
start = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0))),
)
assertThat(update(original, moved, series, "20260722")[CalendarContract.Events.EXDATE])
@@ -748,8 +751,8 @@ class EventWriteMapperTest {
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
).copy(rrule = "FREQ=WEEKLY")
val moved = original.copy(
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
start = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0))),
)
val values = update(original, moved, series, "20260722T000000Z")
@@ -768,8 +771,8 @@ class EventWriteMapperTest {
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
).copy(rrule = "FREQ=WEEKLY")
val moved = original.copy(
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
start = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0))),
)
val values = update(original, moved, series, "20260722T000000")
@@ -787,8 +790,8 @@ class EventWriteMapperTest {
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
).copy(rrule = "FREQ=WEEKLY")
val moved = original.copy(
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
start = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0))),
)
val values = update(original, moved, series, "20260722T090000Z")
@@ -815,8 +818,8 @@ class EventWriteMapperTest {
).copy(rrule = "FREQ=WEEKLY")
val timed = original.copy(
isAllDay = false,
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)),
start = FormTime(LocalDateTime(LocalDate(2026, 7, 15), LocalTime(9, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0))),
)
// The excluded day gains the new 09:00 Berlin time-of-day == 07:00Z.
assertThat(update(original, timed, series, "20260722")[CalendarContract.Events.EXDATE])
@@ -898,8 +901,8 @@ class EventWriteMapperTest {
val series = instantAt("2026-01-07T09:00", "Europe/Berlin")
val original = julySeries()
val moved = original.copy(
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(14, 30)),
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(15, 30)),
start = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(14, 30))),
end = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(15, 30))),
)
val exdate = "20260722T070000Z"
@@ -1387,4 +1390,67 @@ 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(start = FormTime(repeated, UtcOffset(hours = 2)), end = FormTime(repeated, UtcOffset(hours = 1)))
.toWriteTimes(berlin)
assertThat(times.dtStartMillis).isEqualTo(Instant.parse("2026-10-25T00:00:00Z").toEpochMilliseconds())
assertThat(times.dtEndMillis).isEqualTo(Instant.parse("2026-10-25T01:00:00Z").toEpochMilliseconds())
}
@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(start = FormTime(repeated, UtcOffset(hours = 2)), end = FormTime(repeated, UtcOffset(hours = 2)))
val updated = original.copy(
start = original.start.copy(offset = UtcOffset(hours = 1)),
end = original.end.copy(offset = UtcOffset(hours = 1)),
)
val values = update(original, updated)
assertThat(values[CalendarContract.Events.DTSTART])
.isEqualTo(Instant.parse("2026-10-25T01:30:00Z").toEpochMilliseconds())
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(start = FormTime(repeated, UtcOffset(hours = 2)), end = FormTime(repeated, UtcOffset(hours = 1)))
assertThat(update(original, original.copy(title = "Renamed")))
.doesNotContainKey(CalendarContract.Events.DTSTART)
}
@Test
fun `a series anchored on the second repeated hour keeps it through a time edit`() {
// The series starts 02:30 CET on the fall-back day; an occurrence a week
// later gets ten more minutes.
val anchor = Instant.parse("2026-10-25T01:30:00Z").toEpochMilliseconds()
val original = form(
start = LocalDateTime(LocalDate(2026, 11, 1), LocalTime(2, 30)),
end = LocalDateTime(LocalDate(2026, 11, 1), LocalTime(3, 30)),
).copy(rrule = "FREQ=WEEKLY")
val updated = original.copy(end = FormTime(LocalDateTime(LocalDate(2026, 11, 1), LocalTime(3, 40))))
val values = update(original, updated, series = anchor)
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(anchor)
}
@Test
fun `a series anchor moved between the repeated hours follows the form`() {
// Editing the anchor occurrence itself from 02:30 CEST onto 02:30 CET.
val repeated = LocalDateTime(LocalDate(2026, 10, 25), LocalTime(2, 30))
val anchor = Instant.parse("2026-10-25T00:30:00Z").toEpochMilliseconds()
val original = form(start = repeated, end = LocalDateTime(LocalDate(2026, 10, 25), LocalTime(4, 0)))
.copy(start = FormTime(repeated, UtcOffset(hours = 2)), rrule = "FREQ=WEEKLY")
val updated = original.copy(start = FormTime(repeated, UtcOffset(hours = 1)))
val values = update(original, updated, series = anchor)
assertThat(values[CalendarContract.Events.DTSTART])
.isEqualTo(Instant.parse("2026-10-25T01:30:00Z").toEpochMilliseconds())
}
}
@@ -0,0 +1,12 @@
package de.jeanlucmakiola.calendula.data.time
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.datetime.TimeZone
/** A device zone a test sets, by default the machine's own. */
class FakeZoneSource(zone: TimeZone = TimeZone.currentSystemDefault()) : ZoneSource {
override val zone: MutableStateFlow<TimeZone> = MutableStateFlow(zone)
override fun current(): TimeZone = zone.value
}
@@ -5,6 +5,8 @@ import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.UtcOffset
import kotlinx.datetime.toLocalDateTime
import org.junit.jupiter.api.Test
import kotlin.time.Instant
@@ -15,29 +17,29 @@ class EventFormTest {
isAllDay: Boolean = false,
start: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)),
end: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0)),
): EventForm = EventForm(calendarId = calendarId, isAllDay = isAllDay, start = start, end = end)
): EventForm = EventForm(calendarId = calendarId, isAllDay = isAllDay, start = FormTime(start), end = FormTime(end))
@Test
fun `valid timed form has no problems`() {
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 +49,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 +59,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 +68,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 +78,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)
}
@@ -150,8 +152,8 @@ class EventFormTest {
endMillis = 1_781_164_800_000L + 90L * 60L * 1000L,
zone = berlin,
)
assertThat(prefilled.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)))
assertThat(prefilled.end).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 30)))
assertThat(prefilled.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)))
assertThat(prefilled.end.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 30)))
assertThat(prefilled.isAllDay).isFalse()
assertThat(prefilled.calendarId).isEqualTo(7L)
assertThat(prefilled.title).isEqualTo("Stand-up")
@@ -182,7 +184,7 @@ class EventFormTest {
assertThat(prefilled.timezone).isEqualTo("America/New_York")
// 08:00Z is 10:00 in Berlin but 04:00 in New York — the form shows the
// event's own wall-clock, which is what a later save re-anchors to.
assertThat(prefilled.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(4, 0)))
assertThat(prefilled.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(4, 0)))
assertThat(prefilled.populatedFields()).contains(EventFormField.Timezone)
}
@@ -206,68 +208,27 @@ class EventFormTest {
)
// A malformed sync row must not be honoured, nor fail the open.
assertThat(prefilled.timezone).isNull()
assertThat(prefilled.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)))
assertThat(prefilled.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)))
}
@Test
fun `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)),
start = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(8, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(9, 0))),
)
// Unpinned: the form's times already are the local times.
assertThat(base.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 +386,190 @@ 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.dateTime).isEqualTo(prefilled.end.dateTime)
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.dateTime).isLessThan(form.start.dateTime)
assertThat(form.problems(berlin)).isEmpty()
// Read as the same pass at both ends, it really does end first.
assertThat(form.copy(start = FormTime(form.start.dateTime), end = FormTime(form.end.dateTime)).problems(berlin))
.containsExactly(EventFormProblem.EndBeforeStart)
}
@Test
fun `an end picked in the repeated hour moves the way the hand did`() {
// 02:45 CEST to 02:15 CET.
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.dateTime, berlin)).isEqualTo(form)
// Later on the clock: later in time, still on the second pass.
assertThat(form.withEnd(LocalDateTime(LocalDate(2026, 10, 25), LocalTime(2, 50)), berlin).endInstant(berlin))
.isEqualTo(Instant.parse("2026-10-25T01:50:00Z"))
// Earlier on the clock: earlier in time, not an hour earlier.
assertThat(form.withEnd(LocalDateTime(LocalDate(2026, 10, 25), LocalTime(2, 5)), berlin).endInstant(berlin))
.isEqualTo(Instant.parse("2026-10-25T01:05:00Z"))
}
@Test
fun `an end picked in the repeated hour stays after a second-pass start`() {
// 02:10 CET to 04:00, its end set to 02:40: only the second pass is after the start.
val form = form().withTimes(
Instant.parse("2026-10-25T01:10:00Z"),
Instant.parse("2026-10-25T03:00:00Z"),
berlin,
)
val picked = form.withEnd(LocalDateTime(LocalDate(2026, 10, 25), LocalTime(2, 40)), berlin)
assertThat(picked.endInstant(berlin)).isEqualTo(Instant.parse("2026-10-25T01:40:00Z"))
assertThat(picked.problems(berlin)).isEmpty()
}
@Test
fun `a repeated-hour end changed and changed back is the end it was`() {
// 01:00 CEST to 02:30 CEST, its end set to 03:00 and back.
val original = form().withTimes(
Instant.parse("2026-10-24T23:00:00Z"),
Instant.parse("2026-10-25T00:30:00Z"),
berlin,
)
val repeated = LocalDateTime(LocalDate(2026, 10, 25), LocalTime(2, 30))
val away = original.withEnd(LocalDateTime(LocalDate(2026, 10, 25), LocalTime(3, 0)), berlin)
assertThat(away.withEnd(repeated, berlin, original = original.end)).isEqualTo(original)
// Without the opened end to go by, coming back from 03:00 reads as the later pass.
assertThat(away.withEnd(repeated, berlin).endInstant(berlin))
.isEqualTo(Instant.parse("2026-10-25T01:30:00Z"))
}
@Test
fun `a start picked in the repeated hour keeps the length`() {
// 02:30 CET to 03:30, its start set back to 02:10.
val form = form().withTimes(
Instant.parse("2026-10-25T01:30:00Z"),
Instant.parse("2026-10-25T02:30:00Z"),
berlin,
)
val picked = form.withStart(LocalDateTime(LocalDate(2026, 10, 25), LocalTime(2, 10)), berlin)
assertThat(picked.startInstant(berlin)).isEqualTo(Instant.parse("2026-10-25T01:10:00Z"))
assertThat(picked.endInstant(berlin)).isEqualTo(Instant.parse("2026-10-25T02:10:00Z"))
}
@Test
fun `a pinned event's times land in the device zone`() {
val form = EventForm(
calendarId = 1L,
start = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(8, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(9, 0))),
timezone = "America/New_York",
)
val zone = form.resolvedZone(berlin)
// 08:00 New York (EDT, UTC-4) is 14:00 Berlin (CEST, UTC+2).
assertThat(form.startInstant(zone).toLocalDateTime(berlin))
.isEqualTo(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(14, 0)))
assertThat(form.endInstant(zone).toLocalDateTime(berlin))
.isEqualTo(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(15, 0)))
}
@Test
fun `a pinned event's local time crosses the date line with the offset`() {
val form = EventForm(
calendarId = 1L,
start = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(20, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(21, 0))),
timezone = "America/New_York",
)
// 20:00 New York is 02:00 the NEXT day in Berlin.
assertThat(form.startInstant(form.resolvedZone(berlin)).toLocalDateTime(berlin))
.isEqualTo(LocalDateTime(LocalDate(2026, 7, 18), LocalTime(2, 0)))
}
@Test
fun `a pinned event's local time tracks each zone's own DST`() {
// Late March the US has sprung forward and Europe hasn't: five hours, not six.
val form = EventForm(
calendarId = 1L,
start = FormTime(LocalDateTime(LocalDate(2026, 3, 20), LocalTime(8, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 3, 20), LocalTime(9, 0))),
timezone = "America/New_York",
)
assertThat(form.startInstant(form.resolvedZone(berlin)).toLocalDateTime(berlin))
.isEqualTo(LocalDateTime(LocalDate(2026, 3, 20), LocalTime(13, 0)))
}
@Test
fun `offsets are kept only for a repeated hour`() {
val summer = form().withTimes(
Instant.parse("2026-06-11T08:00:00Z"),
Instant.parse("2026-06-11T09:00:00Z"),
berlin,
)
assertThat(summer.start.offset).isNull()
assertThat(summer.end.offset).isNull()
val repeated = form().withTimes(
Instant.parse("2026-10-25T00:45:00Z"),
Instant.parse("2026-10-25T01:15:00Z"),
berlin,
)
assertThat(repeated.start.offset).isEqualTo(UtcOffset(hours = 2))
assertThat(repeated.end.offset).isEqualTo(UtcOffset(hours = 1))
}
@Test
fun `an end changed and changed back is the form it was`() {
val original = detail().toEditForm(
Instant.parse("2026-06-11T08:00:00Z").toEpochMilliseconds(),
Instant.parse("2026-06-11T09:00:00Z").toEpochMilliseconds(),
berlin,
)
val back = original
.withEnd(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 0)), berlin)
.withEnd(original.end.dateTime, berlin)
assertThat(back).isEqualTo(original)
}
@Test
fun `a new zone drops the kept pass, the same zone keeps it`() {
val repeated = form().withTimes(
Instant.parse("2026-10-25T01:30:00Z"),
Instant.parse("2026-10-25T02:00:00Z"),
berlin,
)
assertThat(repeated.withTimezone("Europe/Berlin", berlin)).isEqualTo(repeated.copy(timezone = "Europe/Berlin"))
val moved = repeated.withTimezone("Africa/Lagos", berlin)
assertThat(moved.start.offset).isNull()
assertThat(moved.end.offset).isNull()
// Back on Berlin, a picked 02:30 takes the earlier pass, as any picked time does.
assertThat(moved.withTimezone(null, berlin).startInstant(berlin))
.isEqualTo(Instant.parse("2026-10-25T00:30:00Z"))
}
@Test
fun `an end picked in the spring-forward gap moves on as a start does`() {
val form = form(
start = LocalDateTime(LocalDate(2026, 3, 29), LocalTime(1, 0)),
end = LocalDateTime(LocalDate(2026, 3, 29), LocalTime(1, 30)),
)
val picked = form.withEnd(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(2, 30)), berlin)
assertThat(picked.end.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(3, 30)))
val started = form.withStart(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(2, 30)), berlin)
assertThat(started.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(3, 30)))
}
}
@@ -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 {
@@ -22,8 +24,8 @@ class EventShiftTest {
calendarId = 1L,
title = "Standup",
isAllDay = isAllDay,
start = start,
end = end,
start = FormTime(start),
end = FormTime(end),
timezone = timezone,
)
@@ -34,8 +36,8 @@ class EventShiftTest {
val moved = original.shiftedTo(target, berlin)
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(14, 30)))
assertThat(moved.end).isEqualTo(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(15, 30)))
assertThat(moved.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(14, 30)))
assertThat(moved.end.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(15, 30)))
}
@Test
@@ -52,8 +54,8 @@ class EventShiftTest {
val moved = original.shiftedTo(target, berlin)
assertThat(moved.timezone).isEqualTo("America/New_York")
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(9, 30)))
assertThat(moved.end).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 30)))
assertThat(moved.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(9, 30)))
assertThat(moved.end.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 30)))
}
@Test
@@ -69,10 +71,10 @@ class EventShiftTest {
// Two real hours from 01:00 CET lands at 04:00 CEST: the hour in
// between does not exist.
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(1, 0)))
assertThat(moved.end).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(4, 0)))
assertThat(moved.end.toInstant(berlin) - moved.start.toInstant(berlin))
.isEqualTo(original.end.toInstant(berlin) - original.start.toInstant(berlin))
assertThat(moved.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(1, 0)))
assertThat(moved.end.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(4, 0)))
assertThat(moved.end.dateTime.toInstant(berlin) - moved.start.dateTime.toInstant(berlin))
.isEqualTo(original.end.dateTime.toInstant(berlin) - original.start.dateTime.toInstant(berlin))
}
@Test
@@ -83,13 +85,15 @@ class EventShiftTest {
val moved = original.shiftedTo(target, berlin)
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(3, 30)))
assertThat(moved.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(3, 30)))
}
@Test
fun `a zero-distance drop leaves the form untouched`() {
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.dateTime.toInstant(berlin)
val original = form().withTimes(start, form().end.dateTime.toInstant(berlin), berlin)
assertThat(original.shiftedTo(start, berlin)).isEqualTo(original)
}
@Test
@@ -99,7 +103,7 @@ class EventShiftTest {
val moved = original.shiftedTo(target, newYork)
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 0)))
assertThat(moved.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 0)))
}
@Test
@@ -125,8 +129,8 @@ class EventShiftTest {
val moved = multiDay.shiftedByDays(-2, berlin)
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 9), LocalTime(22, 0)))
assertThat(moved.end).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(2, 0)))
assertThat(moved.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 9), LocalTime(22, 0)))
assertThat(moved.end.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(2, 0)))
}
@Test
@@ -140,11 +144,11 @@ class EventShiftTest {
val moved = overnight.shiftedByDays(7, berlin)
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 3, 28), LocalTime(22, 0)))
assertThat(moved.end.toInstant(berlin) - moved.start.toInstant(berlin))
.isEqualTo(overnight.end.toInstant(berlin) - overnight.start.toInstant(berlin))
assertThat(moved.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 3, 28), LocalTime(22, 0)))
assertThat(moved.end.dateTime.toInstant(berlin) - moved.start.dateTime.toInstant(berlin))
.isEqualTo(overnight.end.dateTime.toInstant(berlin) - overnight.start.dateTime.toInstant(berlin))
// The wall-clock end therefore lands an hour later than a naive +7 days.
assertThat(moved.end).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(5, 0)))
assertThat(moved.end.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(5, 0)))
}
@Test
@@ -158,10 +162,10 @@ class EventShiftTest {
val original = form()
val end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 30)).toInstant(berlin)
val resized = original.resizedTo(original.start.toInstant(berlin), end, berlin)
val resized = original.resizedTo(original.start.dateTime.toInstant(berlin), end, berlin)
assertThat(resized.start).isEqualTo(original.start)
assertThat(resized.end).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 30)))
assertThat(resized.end.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 30)))
}
@Test
@@ -169,9 +173,9 @@ class EventShiftTest {
val original = form()
val end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(1, 0)).toInstant(berlin)
val resized = original.resizedTo(original.start.toInstant(berlin), end, berlin)
val resized = original.resizedTo(original.start.dateTime.toInstant(berlin), end, berlin)
assertThat(resized.end).isEqualTo(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(1, 0)))
assertThat(resized.end.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(1, 0)))
}
@Test
@@ -184,17 +188,79 @@ class EventShiftTest {
// "15:30 Berlin" is 09:30 in New York.
val start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(15, 30)).toInstant(berlin)
val resized = original.resizedTo(start, original.end.toInstant(newYork), berlin)
val resized = original.resizedTo(start, original.end.dateTime.toInstant(newYork), berlin)
assertThat(resized.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(9, 30)))
assertThat(resized.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(9, 30)))
assertThat(resized.end).isEqualTo(original.end)
}
@Test
fun `an all-day event ignores a resize`() {
val allDay = form(isAllDay = true)
val start = allDay.start.toInstant(berlin)
val start = allDay.start.dateTime.toInstant(berlin)
assertThat(allDay.resizedTo(start, start, berlin)).isEqualTo(allDay)
}
// 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.dateTime).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))
}
@Test
fun `a day move onto the repeated hour doesn't depend on the source's season`() {
// 02:30 a week after the fall-back day (CET) and in summer (CEST), both
// moved onto it: the earlier pass either way, as a timeline drop.
val first = Instant.parse("2026-10-25T00:30:00Z")
val november = form().withTimes(
Instant.parse("2026-11-01T01:30:00Z"),
Instant.parse("2026-11-01T02:30:00Z"),
berlin,
)
assertThat(november.shiftedByDays(-7, berlin).startInstant(berlin)).isEqualTo(first)
val summer = form(
start = LocalDateTime(LocalDate(2026, 10, 18), LocalTime(2, 30)),
end = LocalDateTime(LocalDate(2026, 10, 18), LocalTime(3, 30)),
)
assertThat(summer.shiftedByDays(7, berlin).startInstant(berlin)).isEqualTo(first)
}
@Test
fun `a day move onto the spring-forward gap keeps its wall clock`() {
// Saturday 02:30 to 03:30, moved onto Sunday 29 March, when 02:30 is skipped.
val moved = form(
start = LocalDateTime(LocalDate(2026, 3, 28), LocalTime(2, 30)),
end = LocalDateTime(LocalDate(2026, 3, 28), LocalTime(3, 30)),
).shiftedByDays(1, berlin)
assertThat(moved.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(2, 30)))
// It still means 03:30 CEST, an hour long.
assertThat(moved.startInstant(berlin)).isEqualTo(Instant.parse("2026-03-29T01:30:00Z"))
assertThat(moved.endInstant(berlin)).isEqualTo(Instant.parse("2026-03-29T02:30:00Z"))
}
}
@@ -53,8 +53,8 @@ class InsertEventFormTest {
)
assertThat(form.calendarId).isNull() // resolves to last-used / first-writable
assertThat(form.isAllDay).isFalse()
assertThat(form.start).isEqualTo(start)
assertThat(form.end).isEqualTo(end)
assertThat(form.start.dateTime).isEqualTo(start)
assertThat(form.end.dateTime).isEqualTo(end)
assertThat(form.title).isEqualTo("Standup")
assertThat(form.description).isEqualTo("Daily")
assertThat(form.location).isEqualTo("Room 1")
@@ -64,8 +64,8 @@ class InsertEventFormTest {
@Test
fun `missing times default to the next full hour and a one-hour duration`() {
val form = build(title = "Quick note")
assertThat(form.start).isEqualTo(LocalDateTime(2026, 1, 15, 11, 0))
assertThat(form.end).isEqualTo(LocalDateTime(2026, 1, 15, 12, 0))
assertThat(form.start.dateTime).isEqualTo(LocalDateTime(2026, 1, 15, 11, 0))
assertThat(form.end.dateTime).isEqualTo(LocalDateTime(2026, 1, 15, 12, 0))
assertThat(form.title).isEqualTo("Quick note")
}
@@ -73,8 +73,8 @@ class InsertEventFormTest {
fun `timed event with only a start gets a one-hour end`() {
val start = LocalDateTime(2026, 5, 2, 9, 0)
val form = build(beginMillis = millis(start))
assertThat(form.start).isEqualTo(start)
assertThat(form.end).isEqualTo(LocalDateTime(2026, 5, 2, 10, 0))
assertThat(form.start.dateTime).isEqualTo(start)
assertThat(form.end.dateTime).isEqualTo(LocalDateTime(2026, 5, 2, 10, 0))
}
@Test
@@ -82,7 +82,7 @@ class InsertEventFormTest {
val start = LocalDateTime(2026, 5, 2, 9, 0)
val badEnd = LocalDateTime(2026, 5, 2, 8, 0)
val form = build(beginMillis = millis(start), endMillis = millis(badEnd))
assertThat(form.end).isEqualTo(LocalDateTime(2026, 5, 2, 10, 0))
assertThat(form.end.dateTime).isEqualTo(LocalDateTime(2026, 5, 2, 10, 0))
}
@Test
@@ -29,8 +29,8 @@ class ParsedIcsFormTest {
assertThat(form.calendarId).isNull()
assertThat(form.title).isEqualTo("Call")
// 13:00 UTC == 15:00 Berlin (summer).
assertThat(form.start).isEqualTo(LocalDateTime(2026, 6, 18, 15, 0, 0))
assertThat(form.end).isEqualTo(LocalDateTime(2026, 6, 18, 16, 0, 0))
assertThat(form.start.dateTime).isEqualTo(LocalDateTime(2026, 6, 18, 15, 0, 0))
assertThat(form.end.dateTime).isEqualTo(LocalDateTime(2026, 6, 18, 16, 0, 0))
assertThat(form.reminders).containsExactly(5, 10).inOrder()
assertThat(form.rrule).isEqualTo("FREQ=WEEKLY")
}
@@ -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")
}
}
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.ui.common
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.data.time.FakeZoneSource
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
@@ -93,7 +94,7 @@ class RescheduleViewModelTest {
),
)
val repo = CalendarRepositoryImpl(fake, prefs, settings, dispatcher as CoroutineDispatcher)
return RescheduleViewModel(repo, dispatcher)
return RescheduleViewModel(repo, dispatcher, FakeZoneSource())
}
private fun CoroutineScope.activate(vm: RescheduleViewModel): Job =
@@ -2,14 +2,18 @@ package de.jeanlucmakiola.calendula.ui.common
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.repeatedHourOffsetAt
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
import de.jeanlucmakiola.calendula.ui.week.layoutDay
import de.jeanlucmakiola.calendula.ui.week.minutesPast
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 +157,135 @@ 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, kept = brussels.repeatedHourOffsetAt(overnight.start))
assertThat(landed.minutesPast(springForward, brussels)).isEqualTo(3 * 60 + 30)
assertThat(wallClockSpanMinutes(springForward, 3 * 60 + 30, 1.hours, brussels)).isEqualTo(60)
}
@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, kept = brussels.repeatedHourOffsetAt(july))).isEqualTo(first)
assertThat(wallClockInstant(fallBack, slot, brussels, kept = brussels.repeatedHourOffsetAt(november))).isEqualTo(first)
// Later the same day, after the clocks went back: still no say.
val evening = Instant.parse("2026-10-25T18:00:00Z")
assertThat(wallClockInstant(fallBack, slot, brussels, kept = brussels.repeatedHourOffsetAt(evening))).isEqualTo(first)
}
@Test
fun `a bottom resize into the repeated hour keeps a second-pass start's pass`() {
// 02:30 CET (the second pass) to 04:00, its end pulled up to 02:45.
val event = overnight.copy(
start = Instant.parse("2026-10-25T01:30:00Z"),
end = Instant.parse("2026-10-25T03:00:00Z"),
)
val drop = resizedDrop(event, ResizeEdge.Bottom, fallBack, 2 * 60 + 30, 15, brussels)
assertThat(drop.start).isEqualTo(event.start)
assertThat(drop.end).isEqualTo(Instant.parse("2026-10-25T01:45:00Z"))
}
@Test
fun `a resized edge keeps its own pass over the other edge's`() {
// 02:30 CEST (the first pass) to 02:15 CET (the second), the start pulled to 02:00.
val event = overnight.copy(
start = Instant.parse("2026-10-25T00:30:00Z"),
end = Instant.parse("2026-10-25T01:15:00Z"),
)
val drop = resizedDrop(event, ResizeEdge.Top, fallBack, 2 * 60, 60, brussels)
assertThat(drop.start).isEqualTo(Instant.parse("2026-10-25T00:00:00Z"))
assertThat(drop.end).isEqualTo(event.end)
}
@Test
fun `a bottom resize up the repeated hour shortens the event`() {
// 02:20 CEST to 02:10 CET is drawn 02:20 to 03:00; its end pulled up to 02:50.
val event = overnight.copy(
start = Instant.parse("2026-10-25T00:20:00Z"),
end = Instant.parse("2026-10-25T01:10:00Z"),
)
val drop = resizedDrop(event, ResizeEdge.Bottom, fallBack, 2 * 60 + 20, 30, brussels)
assertThat(drop.end).isEqualTo(Instant.parse("2026-10-25T00:50:00Z"))
}
@Test
fun `a bottom resize to a time the grid draws once lands there`() {
// 01:00 CEST to 04:00 CET, its end pulled up to 02:15: only the first pass
// is drawn at 02:15, the second would be drawn down at 03:00.
val event = overnight.copy(
start = Instant.parse("2026-10-24T23:00:00Z"),
end = Instant.parse("2026-10-25T03:00:00Z"),
)
val drop = resizedDrop(event, ResizeEdge.Bottom, fallBack, 60, 75, brussels)
assertThat(drop.end).isEqualTo(Instant.parse("2026-10-25T00:15:00Z"))
}
}
@@ -11,10 +11,15 @@ import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.time.FakeZoneSource
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FormTime
import de.jeanlucmakiola.calendula.domain.startInstant
import de.jeanlucmakiola.calendula.domain.problems
import de.jeanlucmakiola.calendula.domain.endInstant
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -30,6 +35,7 @@ import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@@ -63,11 +69,16 @@ class EventEditViewModelTest {
syncsEvents = syncsEvents,
)
private fun detail(calendarId: Long, rrule: String? = null): EventDetail = EventDetail(
private fun detail(
calendarId: Long,
rrule: String? = null,
start: Instant = Instant.fromEpochMilliseconds(beginMillis),
end: Instant = Instant.fromEpochMilliseconds(endMillis),
): EventDetail = EventDetail(
instance = EventInstance(
instanceId = 42L, eventId = 42L, calendarId = calendarId, title = "Standup",
start = Instant.fromEpochMilliseconds(beginMillis),
end = Instant.fromEpochMilliseconds(endMillis),
start = start,
end = end,
isAllDay = false, color = 0xFF000000.toInt(), location = null,
),
description = null, organizer = null, attendees = emptyList(), rrule = rrule,
@@ -96,11 +107,12 @@ class EventEditViewModelTest {
// Passed in by the tests that need to seed a setting first; a second
// DataStore on the same file would clash, so it is built only once.
s: SettingsPrefs = settings(tempDir),
zones: FakeZoneSource = FakeZoneSource(),
): EventEditViewModel {
val p = prefs(tempDir)
val repo = CalendarRepositoryImpl(fake, p, s, dispatcher as CoroutineDispatcher)
// Only `shareUri()` touches the exporter, and nothing here shares.
return EventEditViewModel(repo, p, s, IcsExporter(ContextWrapper(null)), dispatcher)
return EventEditViewModel(repo, p, s, IcsExporter(ContextWrapper(null)), dispatcher, zones)
}
/** Keep [EventEditViewModel.state] hot so it computes while the test drives it. */
@@ -226,12 +238,12 @@ class EventEditViewModelTest {
advanceUntilIdle()
// Calendar 1 has no length of its own, so it inherits the global 30 min.
assertThat(vm.state.value?.form?.end).isEqualTo(LocalDateTime(2030, 1, 15, 9, 30))
assertThat(vm.state.value?.form?.end?.dateTime).isEqualTo(LocalDateTime(2030, 1, 15, 9, 30))
vm.setCalendar(2L)
advanceUntilIdle()
assertThat(vm.state.value?.form?.end).isEqualTo(LocalDateTime(2030, 1, 15, 17, 0))
assertThat(vm.state.value?.form?.end?.dateTime).isEqualTo(LocalDateTime(2030, 1, 15, 17, 0))
job.cancel()
}
@@ -251,7 +263,7 @@ class EventEditViewModelTest {
vm.setCalendar(2L)
advanceUntilIdle()
assertThat(vm.state.value?.form?.end).isEqualTo(LocalDateTime(2030, 1, 15, 10, 15))
assertThat(vm.state.value?.form?.end?.dateTime).isEqualTo(LocalDateTime(2030, 1, 15, 10, 15))
job.cancel()
}
@@ -272,7 +284,7 @@ class EventEditViewModelTest {
advanceUntilIdle()
// The file owns the length; a calendar switch must not restretch it.
assertThat(vm.state.value?.form?.end).isEqualTo(LocalDateTime(2030, 1, 15, 11, 30))
assertThat(vm.state.value?.form?.end?.dateTime).isEqualTo(LocalDateTime(2030, 1, 15, 11, 30))
job.cancel()
}
@@ -293,15 +305,15 @@ class EventEditViewModelTest {
)
advanceUntilIdle()
assertThat(vm.state.value?.form?.end).isEqualTo(LocalDateTime(2030, 1, 15, 9, 30))
assertThat(vm.state.value?.form?.end?.dateTime).isEqualTo(LocalDateTime(2030, 1, 15, 9, 30))
job.cancel()
}
private fun importedForm(end: LocalDateTime) = EventForm(
calendarId = null,
title = "Imported",
start = LocalDateTime(2030, 1, 15, 9, 0),
end = end,
start = FormTime(LocalDateTime(2030, 1, 15, 9, 0)),
end = FormTime(end),
)
@Test
@@ -321,7 +333,7 @@ class EventEditViewModelTest {
advanceUntilIdle()
// Dates carry no length: the times stay where the form put them.
assertThat(vm.state.value?.form?.end).isEqualTo(LocalDateTime(2030, 1, 15, 10, 0))
assertThat(vm.state.value?.form?.end?.dateTime).isEqualTo(LocalDateTime(2030, 1, 15, 10, 0))
job.cancel()
}
@@ -557,4 +569,93 @@ class EventEditViewModelTest {
assertThat(vm.hasUnsavedChanges.value).isTrue()
job.cancel()
}
@Test
fun `a drafted block opens at its own instants across the fall-back hour`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { calendarsResult = listOf(cal(1L)) }
val vm = viewModel(tempDir, fake, zones = FakeZoneSource(berlin))
val job = activate(vm)
// An hour from 02:30 CEST ends at 02:30 CET, the same wall clock.
vm.openDraft(Instant.parse("2026-10-25T00:30:00Z"), Instant.parse("2026-10-25T01:30:00Z"))
advanceUntilIdle()
val form = vm.state.value!!.form!!
assertThat(form.startInstant(berlin)).isEqualTo(Instant.parse("2026-10-25T00:30:00Z"))
assertThat(form.endInstant(berlin)).isEqualTo(Instant.parse("2026-10-25T01:30:00Z"))
assertThat(form.problems(berlin)).isEmpty()
job.cancel()
}
@Test
fun `a drafted block on the second pass stays on it at its length`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { calendarsResult = listOf(cal(1L)) }
val s = settings(tempDir)
s.setCalendarEventDuration(1L, 480)
val vm = viewModel(tempDir, fake, s, FakeZoneSource(berlin))
val job = activate(vm)
// 02:30 CET to 03:00 CET: the default length mustn't stretch it.
vm.openDraft(Instant.parse("2026-10-25T01:30:00Z"), Instant.parse("2026-10-25T02:00:00Z"))
advanceUntilIdle()
val form = vm.state.value!!.form!!
assertThat(form.startInstant(berlin)).isEqualTo(Instant.parse("2026-10-25T01:30:00Z"))
assertThat(form.endInstant(berlin)).isEqualTo(Instant.parse("2026-10-25T02:00:00Z"))
job.cancel()
}
@Test
fun `a time picked in the repeated hour and back keeps the opened pass`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
// Opened at 02:30 CET (the second pass) to 03:30, its start moved away and back.
val begin = Instant.parse("2026-10-25T01:30:00Z")
val end = Instant.parse("2026-10-25T02:30:00Z")
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(cal(1L))
eventDetailResult = { detail(calendarId = 1L, start = begin, end = end) }
}
val vm = viewModel(tempDir, fake, zones = FakeZoneSource(berlin))
val job = activate(vm)
vm.openForEdit(eventId = 1L, beginMillis = begin.toEpochMilliseconds(), endMillis = end.toEpochMilliseconds())
advanceUntilIdle()
val opened = vm.state.value!!.form!!
vm.setStartTime(LocalTime(4, 0))
vm.setStartTime(LocalTime(2, 30))
assertThat(vm.state.value!!.form).isEqualTo(opened)
job.cancel()
}
private val berlin = TimeZone.of("Europe/Berlin")
@Test
fun `all-day on and off keeps a second-pass event where it was`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val begin = Instant.parse("2026-10-25T01:30:00Z")
val end = Instant.parse("2026-10-25T02:30:00Z")
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(cal(1L))
eventDetailResult = { detail(calendarId = 1L, start = begin, end = end) }
}
val vm = viewModel(tempDir, fake, zones = FakeZoneSource(berlin))
val job = activate(vm)
vm.openForEdit(eventId = 1L, beginMillis = begin.toEpochMilliseconds(), endMillis = end.toEpochMilliseconds())
advanceUntilIdle()
val opened = vm.state.value!!.form!!
vm.setAllDay(true)
vm.setAllDay(false)
advanceUntilIdle()
assertThat(vm.state.value!!.form).isEqualTo(opened)
job.cancel()
}
}
@@ -190,5 +190,121 @@ 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<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)
@Test
fun `a longer run into the repeated fall-back hour is drawn no shorter`() {
val day = LocalDate(2026, 10, 25)
// 02:10 CEST to 02:05 CET (55 min) and to 02:20 CET (70 min): both to 03:00.
val shorter = event(Instant.parse("2026-10-25T00:10:00Z"), Instant.parse("2026-10-25T01:05:00Z"))
val longer = event(Instant.parse("2026-10-25T00:10:00Z"), Instant.parse("2026-10-25T01:20:00Z"), id = 2L)
assertThat(layoutDay(listOf(shorter), day, brussels).single().endMin).isEqualTo(3 * 60)
assertThat(layoutDay(listOf(longer), day, brussels).single().endMin).isEqualTo(3 * 60)
// A run that starts on the second pass reads its plain wall clock.
val second = event(Instant.parse("2026-10-25T01:10:00Z"), Instant.parse("2026-10-25T01:40:00Z"))
assertThat(layoutDay(listOf(second), day, brussels).single().endMin).isEqualTo(2 * 60 + 40)
}
}