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