feat: per-event time zones (#31) #82
@@ -20,7 +20,11 @@ internal data class EventWriteTimes(
|
||||
/**
|
||||
* All-day events live at UTC midnights with an exclusive DTEND (the
|
||||
* CalendarContract convention — a one-day event ends at the next midnight);
|
||||
* timed events resolve their wall-clock values in [zone].
|
||||
* timed events resolve their wall-clock values in the form's own
|
||||
* [EventForm.timezone], falling back to [zone] (the device) when it doesn't pin
|
||||
* one. Passing the device zone is therefore still correct for an unpinned form —
|
||||
* but it no longer overrides a pinned event's zone, which is what used to
|
||||
* silently re-anchor a foreign-zone event to the device on any time edit.
|
||||
*/
|
||||
internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDay) {
|
||||
EventWriteTimes(
|
||||
@@ -31,10 +35,11 @@ internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDa
|
||||
timezone = "UTC",
|
||||
)
|
||||
} else {
|
||||
val writeZone = timezone?.let { runCatching { ZoneId.of(it) }.getOrNull() } ?: zone
|
||||
EventWriteTimes(
|
||||
dtStartMillis = start.toJavaLocalDateTime().atZone(zone).toInstant().toEpochMilli(),
|
||||
dtEndMillis = end.toJavaLocalDateTime().atZone(zone).toInstant().toEpochMilli(),
|
||||
timezone = zone.id,
|
||||
dtStartMillis = start.toJavaLocalDateTime().atZone(writeZone).toInstant().toEpochMilli(),
|
||||
dtEndMillis = end.toJavaLocalDateTime().atZone(writeZone).toInstant().toEpochMilli(),
|
||||
timezone = writeZone.id,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -134,10 +139,14 @@ internal fun buildEventUpdateValues(
|
||||
putAll(eventColorColumns(updated.colorKey, updated.color))
|
||||
}
|
||||
|
||||
// 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.
|
||||
val timesChanged = updated.start != original.start ||
|
||||
updated.end != original.end ||
|
||||
updated.isAllDay != original.isAllDay ||
|
||||
updated.rrule != original.rrule
|
||||
updated.rrule != original.rrule ||
|
||||
updated.timezone != original.timezone
|
||||
if (!timesChanged) return@buildMap
|
||||
|
||||
val newTimes = updated.toWriteTimes(zone)
|
||||
|
||||
@@ -9,8 +9,8 @@ import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* User input for creating an event (and, from v1.3, editing one). Times are
|
||||
* wall-clock values in the device zone; the data layer translates them to
|
||||
* provider millis (all-day events normalise to UTC midnights there).
|
||||
* wall-clock values in [timezone]; the data layer translates them to provider
|
||||
* millis (all-day events normalise to UTC midnights there).
|
||||
*/
|
||||
data class EventForm(
|
||||
val calendarId: Long?,
|
||||
@@ -18,6 +18,23 @@ data class EventForm(
|
||||
val isAllDay: Boolean = false,
|
||||
val start: LocalDateTime,
|
||||
val end: LocalDateTime,
|
||||
/**
|
||||
* The zone [start]/[end] are wall-clock values in, or null to follow the
|
||||
* device — null is not "no zone", it is "whichever zone the device is in
|
||||
* when this is saved", which is what an event authored and lived in one
|
||||
* place wants. The data layer resolves it at write time and always stamps a
|
||||
* concrete `EVENT_TIMEZONE`.
|
||||
*
|
||||
* A non-null value pins the event to a zone regardless of where the device
|
||||
* is, so it keeps tracking that zone's offset across DST. [toEditForm] only
|
||||
* sets it when the stored zone differs from the device's, so merely opening
|
||||
* a local event never reveals the field — and re-opening a pinned one in
|
||||
* another zone round-trips it rather than silently re-anchoring it.
|
||||
*
|
||||
* Always null for all-day events: those are date-anchored, not zone-anchored
|
||||
* (see [EventFormField.Timezone] and the data layer's UTC-midnight rule).
|
||||
*/
|
||||
val timezone: String? = null,
|
||||
val location: String = "",
|
||||
val description: String = "",
|
||||
/** Reminder lead times in minutes before the start, deduplicated. */
|
||||
@@ -66,11 +83,19 @@ data class EventAttendee(
|
||||
|
||||
/**
|
||||
* The form's optional sections. Which ones show by default is a user setting;
|
||||
* the rest unfold behind a "more fields" button.
|
||||
* the rest unfold behind a "more fields" button. Declaration order is the order
|
||||
* they're offered in, so a new constant goes where it belongs on the form, not
|
||||
* at the end.
|
||||
*/
|
||||
enum class EventFormField {
|
||||
Location,
|
||||
Description,
|
||||
/**
|
||||
* Pins the event's wall-clock times to a zone. Offered right after the time
|
||||
* fields it qualifies, and suppressed entirely for all-day events, whose
|
||||
* dates are deliberately zone-free.
|
||||
*/
|
||||
Timezone,
|
||||
Reminders,
|
||||
Recurrence,
|
||||
Availability,
|
||||
@@ -100,8 +125,25 @@ enum class EventFormProblem {
|
||||
* All-day provider times are UTC midnights with an exclusive end; the form
|
||||
* shows the last covered day and keeps placeholder wall-clock times in case
|
||||
* the user switches the event to timed.
|
||||
*
|
||||
* A timed event stored in a zone other than [zone] is prefilled *in its own
|
||||
* zone* and keeps it pinned, so the wall-clock the form shows is the one the
|
||||
* event means ("the New York 09:00 call") and a later save re-anchors it to the
|
||||
* same zone rather than the device's.
|
||||
*/
|
||||
fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone): EventForm {
|
||||
// All-day events are date-anchored and carry a nominal "UTC" that is an
|
||||
// anchor, not a location, so they never pin a zone.
|
||||
val pinnedZone = if (instance.isAllDay) {
|
||||
null
|
||||
} else {
|
||||
eventTimezone
|
||||
?.takeIf { it != zone.id }
|
||||
// An unparseable id (a malformed sync row) can't be honoured or
|
||||
// shown; fall back to the device zone rather than failing the open.
|
||||
?.takeIf { runCatching { TimeZone.of(it) }.isSuccess }
|
||||
}
|
||||
val formZone = pinnedZone?.let { TimeZone.of(it) } ?: zone
|
||||
val (start, end) = if (instance.isAllDay) {
|
||||
val startDate = Instant.fromEpochMilliseconds(beginMillis)
|
||||
.toLocalDateTime(TimeZone.UTC).date
|
||||
@@ -110,8 +152,8 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
|
||||
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(zone) to
|
||||
Instant.fromEpochMilliseconds(endMillis).toLocalDateTime(zone)
|
||||
Instant.fromEpochMilliseconds(beginMillis).toLocalDateTime(formZone) to
|
||||
Instant.fromEpochMilliseconds(endMillis).toLocalDateTime(formZone)
|
||||
}
|
||||
return EventForm(
|
||||
calendarId = instance.calendarId,
|
||||
@@ -119,6 +161,7 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
|
||||
isAllDay = instance.isAllDay,
|
||||
start = start,
|
||||
end = end,
|
||||
timezone = pinnedZone,
|
||||
location = instance.location.orEmpty(),
|
||||
description = description.orEmpty(),
|
||||
reminders = reminders.map { it.minutes }.distinct().sorted(),
|
||||
@@ -184,6 +227,7 @@ fun EventDetail.toEditSnapshot(beginMillis: Long, endMillis: Long, zone: TimeZon
|
||||
fun EventForm.populatedFields(): Set<EventFormField> = buildSet {
|
||||
if (location.isNotBlank()) add(EventFormField.Location)
|
||||
if (description.isNotBlank()) add(EventFormField.Description)
|
||||
if (timezone != null) add(EventFormField.Timezone)
|
||||
if (reminders.isNotEmpty()) add(EventFormField.Reminders)
|
||||
if (rrule != null) add(EventFormField.Recurrence)
|
||||
if (availability != Availability.Busy) add(EventFormField.Availability)
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import java.text.Normalizer
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.TextStyle
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* One selectable zone, resolved for display: [id] is the IANA id we store in
|
||||
* `EVENT_TIMEZONE`, [displayName] its localized name, and [offsetMinutes] its
|
||||
* offset *at a given instant* — zones shift with DST, so an offset is only
|
||||
* meaningful next to the moment it was resolved for.
|
||||
*/
|
||||
data class TimeZoneOption(
|
||||
val id: String,
|
||||
val displayName: String,
|
||||
val offsetMinutes: Int,
|
||||
) {
|
||||
/** The trailing segment of the id ("Europe/Berlin" -> "Berlin"), underscores undone. */
|
||||
val city: String get() = id.substringAfterLast('/').replace('_', ' ')
|
||||
|
||||
/** The leading segment ("Europe/Berlin" -> "Europe"); empty for bare ids like "UTC". */
|
||||
val region: String get() = id.substringBeforeLast('/', missingDelimiterValue = "")
|
||||
}
|
||||
|
||||
/**
|
||||
* Every zone the JVM knows, localized and resolved at [at]. This is ~600
|
||||
* entries, so build it once and filter the result rather than rebuilding per
|
||||
* keystroke.
|
||||
*
|
||||
* Bare three-letter ids ("EST", "CST6CDT") and the legacy SystemV tree are
|
||||
* dropped: they're aliases the tz database keeps for compatibility, they'd
|
||||
* double up the real zones in the list, and none of them is what a user means
|
||||
* when they pick a place.
|
||||
*/
|
||||
fun timeZoneOptions(
|
||||
locale: Locale = Locale.getDefault(),
|
||||
at: Instant = Instant.now(),
|
||||
): List<TimeZoneOption> = ZoneId.getAvailableZoneIds()
|
||||
.asSequence()
|
||||
.filter { it.contains('/') && !it.startsWith("SystemV/") }
|
||||
.map { id ->
|
||||
val zone = ZoneId.of(id)
|
||||
TimeZoneOption(
|
||||
id = id,
|
||||
displayName = zone.getDisplayName(TextStyle.FULL, locale),
|
||||
offsetMinutes = zone.rules.getOffset(at).totalSeconds / 60,
|
||||
)
|
||||
}
|
||||
.sortedWith(compareBy({ it.region }, { it.city }))
|
||||
.toList()
|
||||
|
||||
/**
|
||||
* Resolve a single [id] the same way [timeZoneOptions] would, or null if the tz
|
||||
* database doesn't know it — for labelling one known zone without paying to
|
||||
* build the whole catalogue.
|
||||
*/
|
||||
fun timeZoneOptionOf(
|
||||
id: String,
|
||||
locale: Locale = Locale.getDefault(),
|
||||
at: Instant = Instant.now(),
|
||||
): TimeZoneOption? {
|
||||
val zone = runCatching { ZoneId.of(id) }.getOrNull() ?: return null
|
||||
return TimeZoneOption(
|
||||
id = id,
|
||||
displayName = zone.getDisplayName(TextStyle.FULL, locale),
|
||||
offsetMinutes = zone.rules.getOffset(at).totalSeconds / 60,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* [options] matching [query], best matches first; a blank query returns the list
|
||||
* unchanged. Matching is accent- and case-insensitive and treats underscores as
|
||||
* spaces, so "sao paulo" finds "America/Sao_Paulo".
|
||||
*
|
||||
* Ranking puts a city that *starts with* the query above one that merely
|
||||
* contains it — typing "col" should reach Colombo before Turks_and_Caicos —
|
||||
* and the id is matched ahead of the localized name so a user who knows the
|
||||
* IANA id gets it first.
|
||||
*/
|
||||
fun filterTimeZones(options: List<TimeZoneOption>, query: String): List<TimeZoneOption> {
|
||||
val needle = query.normalizeForSearch()
|
||||
if (needle.isEmpty()) return options
|
||||
return options
|
||||
.mapNotNull { option ->
|
||||
val city = option.city.normalizeForSearch()
|
||||
val id = option.id.normalizeForSearch()
|
||||
val name = option.displayName.normalizeForSearch()
|
||||
val rank = when {
|
||||
city.startsWith(needle) -> 0
|
||||
name.startsWith(needle) -> 1
|
||||
city.contains(needle) -> 2
|
||||
id.contains(needle) -> 3
|
||||
name.contains(needle) -> 4
|
||||
else -> return@mapNotNull null
|
||||
}
|
||||
rank to option
|
||||
}
|
||||
.sortedWith(compareBy({ it.first }, { it.second.city }))
|
||||
.map { it.second }
|
||||
}
|
||||
|
||||
/**
|
||||
* Lowercased, accent-stripped, underscores and slashes flattened to spaces, so
|
||||
* a query types the way a place is spoken rather than the way the tz database
|
||||
* spells it.
|
||||
*/
|
||||
private fun String.normalizeForSearch(): String =
|
||||
Normalizer.normalize(this, Normalizer.Form.NFD)
|
||||
.replace(Regex("\\p{Mn}+"), "")
|
||||
.replace('_', ' ')
|
||||
.replace('/', ' ')
|
||||
.lowercase(Locale.ROOT)
|
||||
.trim()
|
||||
|
||||
/** "GMT+02:00" / "GMT-05:30" / "GMT" — the offset as shown next to a zone. */
|
||||
fun formatGmtOffset(offsetMinutes: Int): String {
|
||||
if (offsetMinutes == 0) return "GMT"
|
||||
val sign = if (offsetMinutes < 0) '-' else '+'
|
||||
val abs = kotlin.math.abs(offsetMinutes)
|
||||
return "GMT%c%02d:%02d".format(sign, abs / 60, abs % 60)
|
||||
}
|
||||
@@ -19,7 +19,14 @@ class EventWriteMapperTest {
|
||||
isAllDay: Boolean = false,
|
||||
start: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)),
|
||||
end: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 30)),
|
||||
): EventForm = EventForm(calendarId = 1L, isAllDay = isAllDay, start = start, end = end)
|
||||
timezone: String? = null,
|
||||
): EventForm = EventForm(
|
||||
calendarId = 1L,
|
||||
isAllDay = isAllDay,
|
||||
start = start,
|
||||
end = end,
|
||||
timezone = timezone,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `timed event resolves wall clock in the given zone`() {
|
||||
@@ -30,6 +37,26 @@ class EventWriteMapperTest {
|
||||
assertThat(times.timezone).isEqualTo("Europe/Berlin")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pinned zone wins over the device zone`() {
|
||||
val times = form(timezone = "America/New_York").toWriteTimes(berlin)
|
||||
// 10:00 in New York (EDT, UTC-4) == 14:00Z, not 08:00Z as Berlin would give.
|
||||
assertThat(times.timezone).isEqualTo("America/New_York")
|
||||
assertThat(times.dtStartMillis).isEqualTo(1_781_186_400_000L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unparseable pinned zone falls back to the device zone`() {
|
||||
val times = form(timezone = "Mars/Olympus_Mons").toWriteTimes(berlin)
|
||||
assertThat(times.timezone).isEqualTo("Europe/Berlin")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all-day ignores a pinned zone and stays UTC`() {
|
||||
val times = form(isAllDay = true, timezone = "America/New_York").toWriteTimes(berlin)
|
||||
assertThat(times.timezone).isEqualTo("UTC")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all-day event lives at UTC midnights with exclusive end`() {
|
||||
val times = form(isAllDay = true).toWriteTimes(berlin)
|
||||
@@ -105,6 +132,42 @@ class EventWriteMapperTest {
|
||||
assertThat(update(original, original.copy())).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `editing the time of a pinned event keeps its zone`() {
|
||||
// The regression this guards: the update used to stamp the device zone
|
||||
// over the event's own, silently un-anchoring a foreign-zone event so it
|
||||
// stopped tracking that zone across DST.
|
||||
val original = form(timezone = "America/New_York")
|
||||
val values = update(original, original.copy(title = "Standup", start = original.start))
|
||||
assertThat(values).doesNotContainKey(CalendarContract.Events.EVENT_TIMEZONE)
|
||||
|
||||
val moved = original.copy(
|
||||
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 30)),
|
||||
)
|
||||
assertThat(update(original, moved)[CalendarContract.Events.EVENT_TIMEZONE])
|
||||
.isEqualTo("America/New_York")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `changing only the zone still moves the event`() {
|
||||
// Same wall-clock, different zone: the instant moves, so DTSTART must be
|
||||
// rewritten even though start/end compare equal.
|
||||
val original = form()
|
||||
val values = update(original, original.copy(timezone = "America/New_York"))
|
||||
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("America/New_York")
|
||||
// 10:00 Berlin (08:00Z) -> 10:00 New York (14:00Z): six hours later.
|
||||
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_186_400_000L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unpinning back to the device zone rewrites the times`() {
|
||||
val original = form(timezone = "America/New_York")
|
||||
val values = update(original, original.copy(timezone = null))
|
||||
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Europe/Berlin")
|
||||
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `text-only edit writes just the changed columns`() {
|
||||
val original = form()
|
||||
|
||||
@@ -117,6 +117,7 @@ class EventFormTest {
|
||||
attendees: List<Attendee> = emptyList(),
|
||||
eventColor: Int? = null,
|
||||
eventColorKey: String? = null,
|
||||
eventTimezone: String? = null,
|
||||
): EventDetail = EventDetail(
|
||||
instance = EventInstance(
|
||||
instanceId = 1L,
|
||||
@@ -138,6 +139,7 @@ class EventFormTest {
|
||||
accessLevel = accessLevel,
|
||||
eventColor = eventColor,
|
||||
eventColorKey = eventColorKey,
|
||||
eventTimezone = eventTimezone,
|
||||
)
|
||||
|
||||
@Test
|
||||
@@ -157,6 +159,56 @@ class EventFormTest {
|
||||
assertThat(prefilled.description).isEqualTo("Body")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toEditForm leaves an event in the device zone unpinned`() {
|
||||
val prefilled = detail(eventTimezone = "Europe/Berlin").toEditForm(
|
||||
beginMillis = 1_781_164_800_000L,
|
||||
endMillis = 1_781_164_800_000L + 3_600_000L,
|
||||
zone = berlin,
|
||||
)
|
||||
// Same zone as the device: pinning it would only make the picker appear
|
||||
// on every ordinary event.
|
||||
assertThat(prefilled.timezone).isNull()
|
||||
assertThat(prefilled.populatedFields()).doesNotContain(EventFormField.Timezone)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toEditForm pins a foreign zone and shows the times in it`() {
|
||||
val prefilled = detail(eventTimezone = "America/New_York").toEditForm(
|
||||
beginMillis = 1_781_164_800_000L, // 08:00Z
|
||||
endMillis = 1_781_164_800_000L + 3_600_000L,
|
||||
zone = berlin,
|
||||
)
|
||||
assertThat(prefilled.timezone).isEqualTo("America/New_York")
|
||||
// 08:00Z is 10:00 in Berlin but 04:00 in New York — the form shows the
|
||||
// event's own wall-clock, which is what a later save re-anchors to.
|
||||
assertThat(prefilled.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(4, 0)))
|
||||
assertThat(prefilled.populatedFields()).contains(EventFormField.Timezone)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toEditForm never pins a zone on an all-day event`() {
|
||||
// All-day rows carry a nominal "UTC" that is an anchor, not a location.
|
||||
val prefilled = detail(isAllDay = true, eventTimezone = "UTC").toEditForm(
|
||||
beginMillis = LocalDate(2026, 6, 11).toEpochDays() * 86_400_000L,
|
||||
endMillis = LocalDate(2026, 6, 12).toEpochDays() * 86_400_000L,
|
||||
zone = berlin,
|
||||
)
|
||||
assertThat(prefilled.timezone).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toEditForm ignores an unparseable stored zone`() {
|
||||
val prefilled = detail(eventTimezone = "Mars/Olympus_Mons").toEditForm(
|
||||
beginMillis = 1_781_164_800_000L,
|
||||
endMillis = 1_781_164_800_000L + 3_600_000L,
|
||||
zone = berlin,
|
||||
)
|
||||
// A malformed sync row must not be honoured, nor fail the open.
|
||||
assertThat(prefilled.timezone).isNull()
|
||||
assertThat(prefilled.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toEditForm turns the exclusive all-day end into the last covered day`() {
|
||||
// 11th..13th = UTC midnights of the 11th and the (exclusive) 14th.
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.Instant
|
||||
import java.util.Locale
|
||||
|
||||
class TimeZoneCatalogTest {
|
||||
|
||||
// A fixed instant so DST-dependent offsets can't drift with the wall clock:
|
||||
// 2026-06-11 is northern summer, i.e. Berlin on CEST and New York on EDT.
|
||||
private val summer: Instant = Instant.parse("2026-06-11T12:00:00Z")
|
||||
private val zones = timeZoneOptions(Locale.ENGLISH, summer)
|
||||
|
||||
private fun filter(query: String) = filterTimeZones(zones, query)
|
||||
|
||||
@Test
|
||||
fun `catalogue holds the real zones and drops the legacy aliases`() {
|
||||
assertThat(zones.map { it.id }).containsAtLeast("Europe/Berlin", "America/New_York")
|
||||
// Bare aliases the tz database keeps for compatibility would double up
|
||||
// the real zones in the picker.
|
||||
assertThat(zones.map { it.id }).containsNoneOf("EST", "CST6CDT", "UTC")
|
||||
assertThat(zones.none { it.id.startsWith("SystemV/") }).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `option exposes city and region split from the id`() {
|
||||
val ny = zones.first { it.id == "America/New_York" }
|
||||
assertThat(ny.city).isEqualTo("New York")
|
||||
assertThat(ny.region).isEqualTo("America")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `offset is resolved at the given instant, not the current one`() {
|
||||
val berlin = zones.first { it.id == "Europe/Berlin" }
|
||||
// CEST in June, so +02:00 — a fixed +01:00 would mean we ignored DST.
|
||||
assertThat(berlin.offsetMinutes).isEqualTo(120)
|
||||
|
||||
val winter = timeZoneOptions(Locale.ENGLISH, Instant.parse("2026-01-11T12:00:00Z"))
|
||||
assertThat(winter.first { it.id == "Europe/Berlin" }.offsetMinutes).isEqualTo(60)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blank query returns everything unchanged`() {
|
||||
assertThat(filter("")).isEqualTo(zones)
|
||||
assertThat(filter(" ")).isEqualTo(zones)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `query matches the city, ignoring case and underscores`() {
|
||||
assertThat(filter("new york").map { it.id }).contains("America/New_York")
|
||||
assertThat(filter("NEW YORK").map { it.id }).contains("America/New_York")
|
||||
assertThat(filter("new_york").map { it.id }).contains("America/New_York")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `query matches accented cities typed plainly`() {
|
||||
// Sao_Paulo has no accent in the id, but its localized name does — the
|
||||
// point is that a user typing plain ASCII still finds it.
|
||||
assertThat(filter("sao paulo").map { it.id }).contains("America/Sao_Paulo")
|
||||
assertThat(filter("zurich").map { it.id }).contains("Europe/Zurich")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `query matches the full IANA id`() {
|
||||
assertThat(filter("europe/berlin").map { it.id }).contains("Europe/Berlin")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a city starting with the query outranks one merely containing it`() {
|
||||
val ids = filter("york").map { it.id }
|
||||
// "New York" contains "york"; nothing starts with it, so it should still
|
||||
// surface rather than being buried.
|
||||
assertThat(ids).contains("America/New_York")
|
||||
|
||||
// "col" starts Colombo but only appears mid-string elsewhere.
|
||||
val col = filter("col").map { it.id }
|
||||
assertThat(col.first()).isEqualTo("Asia/Colombo")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no match yields an empty list rather than everything`() {
|
||||
assertThat(filter("zzzznotazone")).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `single zone resolves the same way the catalogue does`() {
|
||||
val fromCatalogue = zones.first { it.id == "Europe/Berlin" }
|
||||
assertThat(timeZoneOptionOf("Europe/Berlin", Locale.ENGLISH, summer))
|
||||
.isEqualTo(fromCatalogue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown zone id resolves to null`() {
|
||||
assertThat(timeZoneOptionOf("Mars/Olympus_Mons")).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `gmt offsets format with a sign and padding`() {
|
||||
assertThat(formatGmtOffset(0)).isEqualTo("GMT")
|
||||
assertThat(formatGmtOffset(120)).isEqualTo("GMT+02:00")
|
||||
assertThat(formatGmtOffset(-300)).isEqualTo("GMT-05:00")
|
||||
// India is +05:30 — a whole-hour assumption would render this wrong.
|
||||
assertThat(formatGmtOffset(330)).isEqualTo("GMT+05:30")
|
||||
assertThat(formatGmtOffset(-210)).isEqualTo("GMT-03:30")
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,30 @@ on-device — see plan 03):
|
||||
UTC, so zones ahead of UTC can't leak an extra occurrence.
|
||||
- All-day events are normalised to UTC midnights with an exclusive end.
|
||||
|
||||
### Event time zones
|
||||
|
||||
`EventForm.timezone` is the zone its wall-clock times mean, and **null means
|
||||
"the device zone at save time"** — not "no zone". The data layer resolves it in
|
||||
`toWriteTimes` and always stamps a concrete `EVENT_TIMEZONE`, so an ordinary
|
||||
event behaves exactly as it did before the field existed.
|
||||
|
||||
- A non-null value **pins** the event: it keeps tracking that zone's offset
|
||||
across DST no matter where the device is. `toEditForm` only pins when the
|
||||
stored zone differs from the device's, so the optional Time-zone field stays
|
||||
hidden on ordinary events and reveals itself (via `populatedFields`) on
|
||||
foreign-zone ones.
|
||||
- A pinned event is prefilled **in its own zone**, so the form shows the
|
||||
wall-clock the event means rather than the device's rendering of it.
|
||||
- A zone change counts as a **time change** even with the wall-clock untouched
|
||||
(same 09:00 elsewhere is a different instant), so `buildEventUpdateValues`
|
||||
includes it in `timesChanged` and rewrites `DTSTART`.
|
||||
- **All-day events never carry a zone.** They're date-anchored — the UTC
|
||||
midnights above are an anchor, not a location — so the field is withheld from
|
||||
the form entirely and `toWriteTimes` forces `"UTC"` regardless.
|
||||
|
||||
Still device-zone-relative, and knowingly so: `RRULE`'s `UNTIL` rendering and
|
||||
`AllDayReminderEncoding`'s offset (see its KDoc).
|
||||
|
||||
## Save conflicts
|
||||
|
||||
No locking. `openForEdit` keeps an `EditSnapshot` — the prefilled form
|
||||
|
||||
Reference in New Issue
Block a user