diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt index 9758587..4053588 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt @@ -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) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt index 5acc612..9054e20 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt @@ -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 = 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) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalog.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalog.kt new file mode 100644 index 0000000..ca08fc0 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalog.kt @@ -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 = 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, query: String): List { + 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) +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt index 972b9d9..b606a15 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt @@ -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() diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt index fc975c6..a09a1b8 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt @@ -117,6 +117,7 @@ class EventFormTest { attendees: List = 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. diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalogTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalogTest.kt new file mode 100644 index 0000000..fbecfc5 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalogTest.kt @@ -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") + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 21d1f42..a8c3600 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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