feat: per-event time zones (#31) #82

Merged
makiolaj merged 11 commits from feat/timezone-support into release/v2.16.0 2026-07-19 09:49:13 +00:00
7 changed files with 433 additions and 11 deletions
Showing only changes of commit 7634df3cff - Show all commits

View File

@@ -20,7 +20,11 @@ internal data class EventWriteTimes(
/** /**
* All-day events live at UTC midnights with an exclusive DTEND (the * All-day events live at UTC midnights with an exclusive DTEND (the
* CalendarContract convention — a one-day event ends at the next midnight); * 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) { internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDay) {
EventWriteTimes( EventWriteTimes(
@@ -31,10 +35,11 @@ internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDa
timezone = "UTC", timezone = "UTC",
) )
} else { } else {
val writeZone = timezone?.let { runCatching { ZoneId.of(it) }.getOrNull() } ?: zone
EventWriteTimes( EventWriteTimes(
dtStartMillis = start.toJavaLocalDateTime().atZone(zone).toInstant().toEpochMilli(), dtStartMillis = start.toJavaLocalDateTime().atZone(writeZone).toInstant().toEpochMilli(),
dtEndMillis = end.toJavaLocalDateTime().atZone(zone).toInstant().toEpochMilli(), dtEndMillis = end.toJavaLocalDateTime().atZone(writeZone).toInstant().toEpochMilli(),
timezone = zone.id, timezone = writeZone.id,
) )
} }
@@ -134,10 +139,14 @@ internal fun buildEventUpdateValues(
putAll(eventColorColumns(updated.colorKey, updated.color)) 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 || val timesChanged = updated.start != original.start ||
updated.end != original.end || updated.end != original.end ||
updated.isAllDay != original.isAllDay || updated.isAllDay != original.isAllDay ||
updated.rrule != original.rrule updated.rrule != original.rrule ||
updated.timezone != original.timezone
if (!timesChanged) return@buildMap if (!timesChanged) return@buildMap
val newTimes = updated.toWriteTimes(zone) val newTimes = updated.toWriteTimes(zone)

View File

@@ -9,8 +9,8 @@ import kotlin.time.Instant
/** /**
* User input for creating an event (and, from v1.3, editing one). Times are * 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 * wall-clock values in [timezone]; the data layer translates them to provider
* provider millis (all-day events normalise to UTC midnights there). * millis (all-day events normalise to UTC midnights there).
*/ */
data class EventForm( data class EventForm(
val calendarId: Long?, val calendarId: Long?,
@@ -18,6 +18,23 @@ data class EventForm(
val isAllDay: Boolean = false, val isAllDay: Boolean = false,
val start: LocalDateTime, val start: LocalDateTime,
val end: 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 location: String = "",
val description: String = "", val description: String = "",
/** Reminder lead times in minutes before the start, deduplicated. */ /** Reminder lead times in minutes before the start, deduplicated. */
@@ -66,11 +83,19 @@ data class EventAttendee(
/** /**
* The form's optional sections. Which ones show by default is a user setting; * 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 { enum class EventFormField {
Location, Location,
Description, 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, Reminders,
Recurrence, Recurrence,
Availability, Availability,
@@ -100,8 +125,25 @@ enum class EventFormProblem {
* All-day provider times are UTC midnights with an exclusive end; the form * 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 * shows the last covered day and keeps placeholder wall-clock times in case
* the user switches the event to timed. * 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 { 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 (start, end) = if (instance.isAllDay) {
val startDate = Instant.fromEpochMilliseconds(beginMillis) val startDate = Instant.fromEpochMilliseconds(beginMillis)
.toLocalDateTime(TimeZone.UTC).date .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)) val endDate = maxOf(startDate, LocalDate.fromEpochDays(endExclusive.toEpochDays() - 1))
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0)) LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
} else { } else {
Instant.fromEpochMilliseconds(beginMillis).toLocalDateTime(zone) to Instant.fromEpochMilliseconds(beginMillis).toLocalDateTime(formZone) to
Instant.fromEpochMilliseconds(endMillis).toLocalDateTime(zone) Instant.fromEpochMilliseconds(endMillis).toLocalDateTime(formZone)
} }
return EventForm( return EventForm(
calendarId = instance.calendarId, calendarId = instance.calendarId,
@@ -119,6 +161,7 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
isAllDay = instance.isAllDay, isAllDay = instance.isAllDay,
start = start, start = start,
end = end, end = end,
timezone = pinnedZone,
location = instance.location.orEmpty(), location = instance.location.orEmpty(),
description = description.orEmpty(), description = description.orEmpty(),
reminders = reminders.map { it.minutes }.distinct().sorted(), reminders = reminders.map { it.minutes }.distinct().sorted(),
@@ -184,6 +227,7 @@ fun EventDetail.toEditSnapshot(beginMillis: Long, endMillis: Long, zone: TimeZon
fun EventForm.populatedFields(): Set<EventFormField> = buildSet { fun EventForm.populatedFields(): Set<EventFormField> = buildSet {
if (location.isNotBlank()) add(EventFormField.Location) if (location.isNotBlank()) add(EventFormField.Location)
if (description.isNotBlank()) add(EventFormField.Description) if (description.isNotBlank()) add(EventFormField.Description)
if (timezone != null) add(EventFormField.Timezone)
if (reminders.isNotEmpty()) add(EventFormField.Reminders) if (reminders.isNotEmpty()) add(EventFormField.Reminders)
if (rrule != null) add(EventFormField.Recurrence) if (rrule != null) add(EventFormField.Recurrence)
if (availability != Availability.Busy) add(EventFormField.Availability) if (availability != Availability.Busy) add(EventFormField.Availability)

View File

@@ -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)
}

View File

@@ -19,7 +19,14 @@ class EventWriteMapperTest {
isAllDay: Boolean = false, isAllDay: Boolean = false,
start: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)), start: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)),
end: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 30)), 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 @Test
fun `timed event resolves wall clock in the given zone`() { fun `timed event resolves wall clock in the given zone`() {
@@ -30,6 +37,26 @@ class EventWriteMapperTest {
assertThat(times.timezone).isEqualTo("Europe/Berlin") 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 @Test
fun `all-day event lives at UTC midnights with exclusive end`() { fun `all-day event lives at UTC midnights with exclusive end`() {
val times = form(isAllDay = true).toWriteTimes(berlin) val times = form(isAllDay = true).toWriteTimes(berlin)
@@ -105,6 +132,42 @@ class EventWriteMapperTest {
assertThat(update(original, original.copy())).isEmpty() 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 @Test
fun `text-only edit writes just the changed columns`() { fun `text-only edit writes just the changed columns`() {
val original = form() val original = form()

View File

@@ -117,6 +117,7 @@ class EventFormTest {
attendees: List<Attendee> = emptyList(), attendees: List<Attendee> = emptyList(),
eventColor: Int? = null, eventColor: Int? = null,
eventColorKey: String? = null, eventColorKey: String? = null,
eventTimezone: String? = null,
): EventDetail = EventDetail( ): EventDetail = EventDetail(
instance = EventInstance( instance = EventInstance(
instanceId = 1L, instanceId = 1L,
@@ -138,6 +139,7 @@ class EventFormTest {
accessLevel = accessLevel, accessLevel = accessLevel,
eventColor = eventColor, eventColor = eventColor,
eventColorKey = eventColorKey, eventColorKey = eventColorKey,
eventTimezone = eventTimezone,
) )
@Test @Test
@@ -157,6 +159,56 @@ class EventFormTest {
assertThat(prefilled.description).isEqualTo("Body") 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 @Test
fun `toEditForm turns the exclusive all-day end into the last covered day`() { fun `toEditForm turns the exclusive all-day end into the last covered day`() {
// 11th..13th = UTC midnights of the 11th and the (exclusive) 14th. // 11th..13th = UTC midnights of the 11th and the (exclusive) 14th.

View File

@@ -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")
}
}

View File

@@ -99,6 +99,30 @@ on-device — see plan 03):
UTC, so zones ahead of UTC can't leak an extra occurrence. UTC, so zones ahead of UTC can't leak an extra occurrence.
- All-day events are normalised to UTC midnights with an exclusive end. - 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 ## Save conflicts
No locking. `openForEdit` keeps an `EditSnapshot` — the prefilled form No locking. `openForEdit` keeps an `EditSnapshot` — the prefilled form