Follow the hand when a time is picked in the repeated hour (#387)
A time picked in the fall-back hour's repeat, in the form or by a resize handle, now gets its pass from one rule. It keeps the pass the event was opened with when it is that same time again. Otherwise it moves the way the hand did, and stays on the right side of the event's other edge where it can. - The form can set an end in the hour after a second-pass start, and a time changed and changed back is the time it was. - Pulling a resize handle up the repeated hour shortens the event. - A block running into the repeat from before it is drawn to the hour's end, so a longer one never looks shorter. - A drafted block opens the form at its own start and end instants. - The repeated-hour zone label skips the long zone name, and the detail screen's time lines are remembered.
This commit is contained in:
@@ -7,7 +7,9 @@ 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.toKotlinUtcOffset
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Instant
|
||||
import kotlin.time.toJavaInstant
|
||||
@@ -47,6 +49,42 @@ fun Instant.toFormTime(zone: TimeZone): FormTime = FormTime(toLocalDateTime(zone
|
||||
/** 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 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 the way the hand did: 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) when either fits.
|
||||
*/
|
||||
fun FormTime.repicked(
|
||||
picked: LocalDateTime,
|
||||
zone: TimeZone,
|
||||
shownAt: LocalDateTime = dateTime,
|
||||
original: FormTime? = null,
|
||||
notBefore: Instant? = null,
|
||||
notAfter: Instant? = null,
|
||||
): FormTime {
|
||||
if (picked == dateTime && picked == shownAt) return this
|
||||
val zid = zone.toJavaZoneId()
|
||||
val offsets = zid.rules.getValidOffsets(picked.toJavaLocalDateTime())
|
||||
if (offsets.size < 2) return FormTime(picked)
|
||||
val passes = offsets.map { picked.toInstant(it.toKotlinUtcOffset()) }.sorted()
|
||||
val fitting = passes.filter { (notBefore == null || it >= notBefore) && (notAfter == null || it <= notAfter) }
|
||||
.ifEmpty { passes }
|
||||
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)
|
||||
|
||||
@@ -64,16 +102,29 @@ fun EventForm.withTimes(start: Instant, end: Instant, zone: TimeZone): EventForm
|
||||
fun EventForm.withTimezone(zoneId: String?, deviceZone: TimeZone): EventForm {
|
||||
val pinned = copy(timezone = zoneId)
|
||||
if (pinned.resolvedZone(deviceZone) == resolvedZone(deviceZone)) return pinned
|
||||
return pinned.copy(start = FormTime(start.dateTime), end = FormTime(end.dateTime))
|
||||
return pinned.withoutKeptPasses()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 takes the earlier — unless it is the
|
||||
* end the form already has.
|
||||
* 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.withEnd(end: LocalDateTime): EventForm =
|
||||
if (end == this.end.dateTime) this else copy(end = FormTime(end))
|
||||
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 —
|
||||
|
||||
@@ -166,11 +166,27 @@ fun zoneDescriptor(option: TimeZoneOption): String {
|
||||
* [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 if (abbrev.startsWith("GMT")) formatGmtOffset(option.offsetMinutes) else abbrev
|
||||
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
|
||||
|
||||
@@ -95,6 +95,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
|
||||
@@ -239,18 +240,23 @@ 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 length (#153), in minutes.
|
||||
var createDurationMinutes by rememberSaveable { mutableStateOf<Int?>(null) }
|
||||
var heldCreateDurationMinutes by remember { mutableStateOf<Int?>(null) }
|
||||
val onCreateSpan: (LocalDate, Int?, Int?) -> Unit = { date, startMinutes, durationMinutes ->
|
||||
// A drafted block's start and end (#153), as epoch millis: the instants it
|
||||
// shows, which minutes on a day could only say again in some zone (#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
|
||||
heldCreateDurationMinutes = durationMinutes
|
||||
createDurationMinutes = durationMinutes
|
||||
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
|
||||
@@ -386,8 +392,8 @@ fun CalendarHost(
|
||||
createDateIso = iso
|
||||
heldCreateMinutes = null
|
||||
createStartMinutes = null
|
||||
heldCreateDurationMinutes = null
|
||||
createDurationMinutes = null
|
||||
heldCreateDraftMillis = null
|
||||
createDraftMillis = null
|
||||
onWidgetNavConsumed()
|
||||
}
|
||||
null -> {}
|
||||
@@ -591,7 +597,7 @@ fun CalendarHost(
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
onCreateEvent = onCreateEvent,
|
||||
onCreateSpan = onCreateSpan,
|
||||
onCreateDraft = onCreateDraft,
|
||||
)
|
||||
ViewScreen.Month -> MonthScreen(
|
||||
onOpenDay = onOpenDay,
|
||||
@@ -680,7 +686,7 @@ fun CalendarHost(
|
||||
EventEditScreen(
|
||||
initialDateIso = iso,
|
||||
initialStartMinutes = createStartMinutes ?: heldCreateMinutes,
|
||||
initialDurationMinutes = createDurationMinutes ?: heldCreateDurationMinutes,
|
||||
initialDraft = createDraftMillis ?: heldCreateDraftMillis,
|
||||
onClose = { createDateIso = null },
|
||||
onSaved = { createDateIso = null },
|
||||
onManageCalendars = { showCalendars = true },
|
||||
|
||||
@@ -2,8 +2,7 @@ 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 de.jeanlucmakiola.calendula.domain.zoneAbbreviationAt
|
||||
import kotlinx.datetime.TimeZone
|
||||
import java.time.LocalTime
|
||||
import kotlin.time.Instant
|
||||
@@ -82,7 +81,7 @@ private const val MINUTES_PER_DAY = 1_440
|
||||
*/
|
||||
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)
|
||||
val abbreviation = zoneAbbreviationAt(zone.id, locale, instant.toJavaInstant(), ::icuTimeZoneRegion)
|
||||
?: return formatted
|
||||
return "$formatted ${zoneAbbreviation(option)}"
|
||||
return "$formatted $abbreviation"
|
||||
}
|
||||
|
||||
@@ -46,7 +46,10 @@ 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
|
||||
@@ -323,9 +326,8 @@ data class TimelineResizeDrop(val event: EventInstance, val start: Instant, val
|
||||
* [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). The held edge keeps its
|
||||
* own pass of a repeated hour, else the other edge's: an end pulled into the
|
||||
* later pass an event starts in stays after that start.
|
||||
* drawn to that hour's end, not at its own time (#387). The held edge's pass of a
|
||||
* repeated hour follows the hand from where the edge was drawn: see [repicked].
|
||||
*/
|
||||
internal fun resizedDrop(
|
||||
event: EventInstance,
|
||||
@@ -335,17 +337,26 @@ internal fun resizedDrop(
|
||||
spanMin: Int,
|
||||
zone: TimeZone,
|
||||
): TimelineResizeDrop {
|
||||
val startKept = zone.repeatedHourOffsetAt(event.start)
|
||||
val endKept = zone.repeatedHourOffsetAt(event.end)
|
||||
val drawn = wallClockSpan(event, zone)
|
||||
return TimelineResizeDrop(
|
||||
event = event,
|
||||
start = if (edge == ResizeEdge.Top) {
|
||||
wallClockInstant(date, startMin, zone, kept = startKept ?: endKept)
|
||||
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) {
|
||||
wallClockInstant(date, startMin + spanMin, zone, kept = endKept ?: startKept)
|
||||
event.end.toFormTime(zone).repicked(
|
||||
wallClockTime(date, startMin + spanMin),
|
||||
zone,
|
||||
shownAt = wallClockTime(drawn.day, drawn.endMin),
|
||||
notBefore = event.start,
|
||||
).instantIn(zone)
|
||||
} else {
|
||||
event.end
|
||||
},
|
||||
|
||||
@@ -367,7 +367,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) {
|
||||
@@ -407,7 +407,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(
|
||||
@@ -777,18 +777,28 @@ private fun linkifyUrls(text: String, linkColor: Color): AnnotatedString {
|
||||
* 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)
|
||||
// Keyed: a repeated-hour time looks its zone's abbreviation up (#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
|
||||
|
||||
@@ -205,7 +205,7 @@ fun EventEditScreen(
|
||||
onSaved: () -> Unit,
|
||||
editKey: LongArray? = null,
|
||||
initialStartMinutes: Int? = null,
|
||||
initialDurationMinutes: Int? = null,
|
||||
initialDraft: LongArray? = null,
|
||||
initialForm: EventForm? = null,
|
||||
initialFormSource: ImportSource = ImportSource.File,
|
||||
onManageCalendars: (() -> Unit)? = null,
|
||||
@@ -222,10 +222,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, initialDurationMinutes)
|
||||
viewModel.openNew(date, initialStartMinutes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,12 +22,12 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.endInstant
|
||||
import de.jeanlucmakiola.calendula.domain.nextFullHour
|
||||
import de.jeanlucmakiola.calendula.domain.instantIn
|
||||
import de.jeanlucmakiola.calendula.domain.toFormTime
|
||||
import de.jeanlucmakiola.calendula.domain.FormTime
|
||||
import de.jeanlucmakiola.calendula.domain.resolvedZone
|
||||
import de.jeanlucmakiola.calendula.domain.startInstant
|
||||
import de.jeanlucmakiola.calendula.domain.withEnd
|
||||
import de.jeanlucmakiola.calendula.domain.withoutKeptPasses
|
||||
import de.jeanlucmakiola.calendula.domain.withStart
|
||||
import de.jeanlucmakiola.calendula.domain.withTimes
|
||||
import de.jeanlucmakiola.calendula.domain.withTimezone
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
@@ -65,6 +65,7 @@ import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val TAG = "EventEdit"
|
||||
@@ -342,14 +343,10 @@ 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 its length as
|
||||
* [durationMinutes] instead: a real length, which a wall-clock end can't
|
||||
* give across a DST change or a start pushed out of the spring-forward gap.
|
||||
* A start in the fall-back hour's repeat takes the earlier pass.
|
||||
* 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, durationMinutes: Int? = null) {
|
||||
fun openNew(date: LocalDate, startMinutes: Int? = null) {
|
||||
if (_form.value != null) return
|
||||
val zone = deviceZone()
|
||||
val now = Clock.System.now()
|
||||
@@ -359,10 +356,24 @@ class EventEditViewModel @Inject constructor(
|
||||
date == now.toLocalDateTime(zone).date -> nextFullHour(now)
|
||||
else -> LocalDateTime(date, LocalTime(9, 0)).toInstant(zone)
|
||||
}
|
||||
val chosen = durationMinutes?.takeIf { startMinutes != null && it > 0 }
|
||||
val endAt = startAt + (chosen ?: SettingsPrefs.DEFAULT_EVENT_DURATION).minutes
|
||||
val endAt = startAt + SettingsPrefs.DEFAULT_EVENT_DURATION.minutes
|
||||
_form.value = EventForm(calendarId = null, start = startAt.toFormTime(zone), end = endAt.toFormTime(zone))
|
||||
if (chosen != null) _durationTouched.value = true
|
||||
applyDefaultReminder()
|
||||
applyDefaultDuration()
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialise a fresh form for a block drafted on the timeline (#153), from
|
||||
* [start] to [end] — the instants it shows, so a pass of the fall-back hour
|
||||
* or a start pushed out of the spring-forward gap carries over as drawn
|
||||
* (#387). Its length is the user's pick, so the default length leaves it be.
|
||||
* No-op when a form is already open, as [openNew].
|
||||
*/
|
||||
fun openDraft(start: Instant, end: Instant) {
|
||||
if (_form.value != null) return
|
||||
val zone = deviceZone()
|
||||
_form.value = EventForm(calendarId = null, start = start.toFormTime(zone), end = end.toFormTime(zone))
|
||||
_durationTouched.value = true
|
||||
applyDefaultReminder()
|
||||
applyDefaultDuration()
|
||||
}
|
||||
@@ -589,11 +600,7 @@ class EventEditViewModel @Inject constructor(
|
||||
// which is the same thing a fresh event gets.
|
||||
// An all-day form's times are bare dates, so a kept pass goes with it.
|
||||
update {
|
||||
if (value) {
|
||||
it.copy(isAllDay = true, timezone = null, start = FormTime(it.start.dateTime), end = FormTime(it.end.dateTime))
|
||||
} else {
|
||||
it.copy(isAllDay = false)
|
||||
}
|
||||
if (value) it.copy(isAllDay = true, timezone = null).withoutKeptPasses() 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).
|
||||
@@ -687,12 +694,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.withEnd(LocalDateTime(date, it.end.time)) }
|
||||
moveEnd { LocalDateTime(date, it.time) }
|
||||
}
|
||||
|
||||
fun setEndTime(time: LocalTime) {
|
||||
_durationTouched.value = true
|
||||
update { it.withEnd(LocalDateTime(it.end.date, time)) }
|
||||
moveEnd { LocalDateTime(it.date, time) }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -834,15 +841,20 @@ class EventEditViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
private fun moveStart(transform: (LocalDateTime) -> LocalDateTime) = update { form ->
|
||||
val zone = form.resolvedZone(deviceZone())
|
||||
val duration = form.endInstant(zone) - form.startInstant(zone)
|
||||
// A picked time can't say which pass of a repeated hour it means; only
|
||||
// an unchanged start keeps its own (see [withEnd]).
|
||||
val picked = transform(form.start.dateTime)
|
||||
val newStart = (if (picked == form.start.dateTime) form.start else FormTime(picked)).instantIn(zone)
|
||||
form.withTimes(newStart, newStart + duration, zone)
|
||||
form.withStart(transform(form.start.dateTime), form.resolvedZone(deviceZone()), opened(form)?.start)
|
||||
}
|
||||
|
||||
private fun moveEnd(transform: (LocalDateTime) -> LocalDateTime) = update { form ->
|
||||
form.withEnd(transform(form.end.dateTime), form.resolvedZone(deviceZone()), 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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -328,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.
|
||||
@@ -514,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)
|
||||
@@ -538,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,
|
||||
) {
|
||||
@@ -597,12 +598,11 @@ private fun WeekContent(
|
||||
)
|
||||
}
|
||||
|
||||
// A drafted block (#153) opens the new-event form at its start, for its real
|
||||
// length: the end it is drawn to differs on a DST change (#387).
|
||||
// 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 start = draft.start.toLocalDateTime(zone)
|
||||
onCreateSpan(start.date, start.time.toSecondOfDay() / 60, (draft.end - draft.start).inWholeMinutes.toInt())
|
||||
onCreateDraft(draft.start, draft.end)
|
||||
}
|
||||
|
||||
// Selection (#141) ends on back, a page or view change, or any tap.
|
||||
|
||||
@@ -34,7 +34,9 @@ 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
|
||||
@@ -45,6 +47,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
|
||||
|
||||
@@ -358,9 +362,9 @@ internal fun wallClockMinutes(instant: Instant, day: LocalDate, zone: TimeZone):
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* wall clock. One ending in the fall-back hour's repeat that began before it
|
||||
* covers the hour's wall clock from its start to where the hour ends, and is
|
||||
* drawn to there: its own wall-clock end reads earlier than its length (#387).
|
||||
*/
|
||||
internal fun wallClockEndMinutes(
|
||||
startMin: Int,
|
||||
@@ -370,23 +374,25 @@ internal fun wallClockEndMinutes(
|
||||
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())
|
||||
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 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.
|
||||
* 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(end: Instant, day: LocalDate, zone: TimeZone): Int? {
|
||||
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 local = transition.dateTimeBefore
|
||||
val days = (local.toLocalDate().toEpochDay() - day.toEpochDays()).toInt()
|
||||
return days * MINUTES_PER_DAY + local.toLocalTime().toSecondOfDay() / 60
|
||||
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()))
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -413,17 +413,63 @@ class EventFormTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an end picked by hand takes the earlier repeated hour`() {
|
||||
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)).isEqualTo(form)
|
||||
val picked = form.withEnd(LocalDateTime(LocalDate(2026, 10, 25), LocalTime(2, 50)))
|
||||
assertThat(picked.end.offset).isNull()
|
||||
assertThat(picked.endInstant(berlin)).isEqualTo(Instant.parse("2026-10-25T00:50:00Z"))
|
||||
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
|
||||
@@ -494,8 +540,8 @@ class EventFormTest {
|
||||
berlin,
|
||||
)
|
||||
val back = original
|
||||
.withEnd(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 0)))
|
||||
.withEnd(original.end.dateTime)
|
||||
.withEnd(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 0)), berlin)
|
||||
.withEnd(original.end.dateTime, berlin)
|
||||
assertThat(back).isEqualTo(original)
|
||||
}
|
||||
|
||||
|
||||
@@ -265,4 +265,15 @@ class TimelineDropTest {
|
||||
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"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,11 +64,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,
|
||||
@@ -414,7 +419,7 @@ class EventEditViewModelTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a drafted block opens at its real length across the fall-back hour`(
|
||||
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)) }
|
||||
@@ -422,7 +427,7 @@ class EventEditViewModelTest {
|
||||
val job = activate(vm)
|
||||
|
||||
// An hour from 02:30 CEST ends at 02:30 CET, the same wall clock.
|
||||
vm.openNew(LocalDate(2026, 10, 25), startMinutes = 2 * 60 + 30, durationMinutes = 60)
|
||||
vm.openDraft(Instant.parse("2026-10-25T00:30:00Z"), Instant.parse("2026-10-25T01:30:00Z"))
|
||||
advanceUntilIdle()
|
||||
|
||||
val form = vm.state.value!!.form!!
|
||||
@@ -433,21 +438,46 @@ class EventEditViewModelTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a drafted block in the spring-forward gap keeps its length`(
|
||||
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 vm = viewModel(tempDir, fake).apply { deviceZone = { berlin } }
|
||||
val s = settings(tempDir)
|
||||
s.setCalendarEventDuration(1L, 480)
|
||||
val vm = viewModel(tempDir, fake, s).apply { deviceZone = { berlin } }
|
||||
val job = activate(vm)
|
||||
|
||||
// 02:30 doesn't exist on 29 March: the start moves on to 03:30, the end with it.
|
||||
vm.openNew(LocalDate(2026, 3, 29), startMinutes = 2 * 60 + 30, durationMinutes = 30)
|
||||
// 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.start.dateTime).isEqualTo(LocalDateTime(2026, 3, 29, 3, 30))
|
||||
assertThat(form.end.dateTime).isEqualTo(LocalDateTime(2026, 3, 29, 4, 0))
|
||||
assertThat(form.problems(berlin)).isEmpty()
|
||||
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).apply { deviceZone = { 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()
|
||||
}
|
||||
|
||||
|
||||
@@ -294,4 +294,17 @@ class WeekLayoutTest {
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user