From 734bcae02d0bc053850b1829ef606e3248ddcde7 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 19 Jul 2026 11:15:48 +0200 Subject: [PATCH] fix(timezone): resolve the abbreviation in the zone's own region MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit java.util gave abbreviations only for zones in the display locale's region: an en-DE phone saw "CEST" for Berlin but "GMT-4" for New York, and an en-US phone the reverse — ICU only surfaces the short names commonly used where the reader is. Verified on-device (en-DE): US zones fell back to the offset, EU ones resolved. Ask ICU in the display language but the *zone's* region instead — New York in en-US, Berlin in en-DE — using android.icu's zone→region map. On-device that lights up EDT/PDT/CDT, plus BST, AEST, IST, JST that showed only the offset before. Where a region still has no name (Athens in en-GR) the device locale sometimes does, so fall back to it, then to the offset. The region lookup needs android.icu, which domain/ can't import, so it's injected: timeZoneOptions takes a regionOf lambda (default none, keeping the module pure and JVM-tested), and the UI passes icuTimeZoneRegion. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/domain/TimeZoneCatalog.kt | 90 +++++++++++++------ .../calendula/ui/common/TimeZonePicker.kt | 2 +- .../calendula/ui/common/TimeZoneRegion.kt | 16 ++++ .../calendula/ui/detail/EventDetailScreen.kt | 3 +- .../calendula/ui/edit/EventEditScreen.kt | 3 +- 5 files changed, 86 insertions(+), 28 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZoneRegion.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalog.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalog.kt index 97aa834..e688a66 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalog.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalog.kt @@ -33,28 +33,63 @@ data class TimeZoneOption( val region: String get() = id.substringBeforeLast('/', missingDelimiterValue = "") } -private fun optionFor(zone: ZoneId, locale: Locale, at: Instant): TimeZoneOption { - val offsetSeconds = zone.rules.getOffset(at).totalSeconds - return TimeZoneOption( - id = zone.id, - displayName = zone.getDisplayName(TextStyle.FULL, locale), - // The abbreviation ("EDT"/"CEST", DST-correct at [at]). Resolved through - // java.util.TimeZone, NOT java.time's "zzz" formatter: on Android the - // latter has no short specific-zone names and silently degrades every - // zone to a "GMT-4" form (it agrees with java.util only on desktop), so - // "zzz" cost us the real abbreviation on the one platform that ships. - // java.util is ICU-backed and returns the abbreviation on both. Zones - // with genuinely no named abbreviation still fall back to a GMT form, - // which is what we'd show anyway. - shortName = java.util.TimeZone.getTimeZone(zone.id) - .getDisplayName(zone.rules.isDaylightSavings(at), java.util.TimeZone.SHORT, locale), - offsetMinutes = offsetSeconds / 60, - ) +private fun optionFor( + zone: ZoneId, + locale: Locale, + at: Instant, + regionOf: (String) -> String?, +): TimeZoneOption = TimeZoneOption( + id = zone.id, + displayName = zone.getDisplayName(TextStyle.FULL, locale), + shortName = resolveAbbreviation(zone.id, zone.rules.isDaylightSavings(at), locale, regionOf), + offsetMinutes = zone.rules.getOffset(at).totalSeconds / 60, +) + +/** + * The zone abbreviation ("EDT", "CEST"), DST-correct at the resolved instant, or + * a "GMT+05:30" form when no name exists. + * + * Two things make this fiddlier than it looks. First, it goes through + * java.util.TimeZone, NOT java.time's "zzz" formatter: on Android the latter has + * no short specific-zone names and degrades every zone to a "GMT-4" form (it + * agrees with java.util only on desktop, which is why desktop can't catch it). + * Second, ICU only surfaces the short name *commonly used in the display + * locale's region* — a German-region English phone (en-DE) is shown "CEST" but + * not "EDT", and an en-US phone the reverse. So ask in the display *language* + * but the *zone's* region ("America/New_York" in en-US, "Europe/Berlin" in + * en-DE) via [regionOf]. That covers almost everything; where a region still + * yields no name (Athens in en-GR) the plain device locale sometimes does, so + * try that next; failing both, the caller shows the offset. + * + * [regionOf] maps an IANA id to an ISO 3166 region and is injected because it + * needs `android.icu`, which this pure-JVM module can't import. The default + * (no region) leaves only the device-locale path — which is all a JVM test has + * anyway, and enough there since desktop ICU isn't region-gated the same way. + */ +private fun resolveAbbreviation( + id: String, + dst: Boolean, + locale: Locale, + regionOf: (String) -> String?, +): String { + fun shortIn(loc: Locale): String = + java.util.TimeZone.getTimeZone(id).getDisplayName(dst, java.util.TimeZone.SHORT, loc) + val regional = regionOf(id) + ?.takeIf { it.length == 2 } + ?.let { shortIn(Locale(locale.language, it)) } + if (regional != null && !regional.looksLikeOffset()) return regional + // Either no region, or the region has no name for this zone: the device + // locale is the next-best shot, and the offset the last resort. + return shortIn(locale) } +/** True for the offset-style names ICU returns when a zone has no abbreviation. */ +private fun String.looksLikeOffset(): Boolean = this == "UTC" || startsWith("GMT") + /** * Every zone the JVM knows, resolved at [at]. This is ~600 entries, so build it - * once and filter the result rather than rebuilding per keystroke. + * once and filter the result rather than rebuilding per keystroke. [regionOf] + * (see [resolveAbbreviation]) supplies the zone's region for the abbreviation. * * Bare three-letter ids ("EST", "CST6CDT") and the legacy SystemV tree are * dropped: they're aliases the tz database keeps for compatibility, they'd @@ -64,10 +99,11 @@ private fun optionFor(zone: ZoneId, locale: Locale, at: Instant): TimeZoneOption fun timeZoneOptions( locale: Locale = Locale.getDefault(), at: Instant = Instant.now(), + regionOf: (String) -> String? = { null }, ): List = ZoneId.getAvailableZoneIds() .asSequence() .filter { it.contains('/') && !it.startsWith("SystemV/") } - .map { optionFor(ZoneId.of(it), locale, at) } + .map { optionFor(ZoneId.of(it), locale, at, regionOf) } .sortedWith(compareBy({ it.region }, { it.city })) .toList() @@ -80,20 +116,24 @@ fun timeZoneOptionOf( id: String, locale: Locale = Locale.getDefault(), at: Instant = Instant.now(), + regionOf: (String) -> String? = { null }, ): TimeZoneOption? { val zone = runCatching { ZoneId.of(id) }.getOrNull() ?: return null - return optionFor(zone, locale, at) + return optionFor(zone, locale, at, regionOf) } /** - * The short descriptor shown beneath the id, e.g. "CET · GMT+01:00". When the - * abbreviation is itself offset-shaped ("UTC", "GMT+05:30") the offset would - * only repeat it, so the abbreviation stands alone. + * The short descriptor shown beneath the id, e.g. "CET · GMT+01:00". "UTC" reads + * fine alone; any other offset-shaped name is normalised to our own GMT form so + * the offset isn't stated twice in ICU's spelling and ours. */ fun zoneDescriptor(option: TimeZoneOption): String { val abbrev = option.shortName - val offsetShaped = abbrev == "UTC" || abbrev.startsWith("GMT") - return if (offsetShaped) abbrev else "$abbrev · ${formatGmtOffset(option.offsetMinutes)}" + return when { + abbrev == "UTC" -> "UTC" + abbrev.startsWith("GMT") -> formatGmtOffset(option.offsetMinutes) + else -> "$abbrev · ${formatGmtOffset(option.offsetMinutes)}" + } } /** diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZonePicker.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZonePicker.kt index ed74451..fc5d472 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZonePicker.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZonePicker.kt @@ -67,7 +67,7 @@ fun TimeZonePickerDialog( // ~600 zones, each resolving a localized name and a DST-aware offset: build // once per open, then filter the result per keystroke. val locale = currentLocale() - val allZones = remember(locale) { timeZoneOptions(locale) } + val allZones = remember(locale) { timeZoneOptions(locale, regionOf = ::icuTimeZoneRegion) } val matches = remember(allZones, query) { filterTimeZones(allZones, query) } val recentZones = remember(allZones, recents) { recents.mapNotNull { id -> allZones.firstOrNull { it.id == id } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZoneRegion.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZoneRegion.kt new file mode 100644 index 0000000..d8e5769 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZoneRegion.kt @@ -0,0 +1,16 @@ +package de.jeanlucmakiola.calendula.ui.common + +import android.icu.util.TimeZone as IcuTimeZone + +/** + * The ISO 3166 region an IANA zone belongs to ("America/New_York" -> "US"), or + * null when ICU has none or maps it to the multi-country "001" ("Etc/UTC"). + * + * This is the `android.icu`-backed bridge the pure-JVM + * [de.jeanlucmakiola.calendula.domain.timeZoneOptions] takes as `regionOf`, so + * it can ask ICU for a zone's abbreviation in that zone's own region — the only + * region for which ICU will surface it (see `resolveAbbreviation`). Lives here, + * not in `domain/`, precisely because it needs an Android import. + */ +fun icuTimeZoneRegion(id: String): String? = + runCatching { IcuTimeZone.getRegion(id) }.getOrNull()?.takeIf { it.length == 2 } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt index 83c7032..639f7fd 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt @@ -98,6 +98,7 @@ import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf import de.jeanlucmakiola.calendula.domain.zoneDescriptor import de.jeanlucmakiola.floret.identity.predictiveBack import de.jeanlucmakiola.calendula.ui.common.CalendarFailure +import de.jeanlucmakiola.calendula.ui.common.icuTimeZoneRegion import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors import de.jeanlucmakiola.calendula.ui.common.eventFill import de.jeanlucmakiola.floret.components.OptionCard @@ -792,7 +793,7 @@ private fun reminderLeadText(reminder: Reminder): String = reminderLeadTimeLabel private fun foreignTimeZone(tz: String?, isAllDay: Boolean, locale: Locale): TimeZoneOption? { if (isAllDay || tz.isNullOrBlank()) return null if (tz == ZoneId.systemDefault().id) return null - return timeZoneOptionOf(tz, locale) + return timeZoneOptionOf(tz, locale, regionOf = ::icuTimeZoneRegion) } /** Wrap http(s) URLs in [text] as tappable links tinted [linkColor]. */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index e2f3a33..3f5d920 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt @@ -131,6 +131,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarPickerGroups import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow import de.jeanlucmakiola.calendula.ui.common.TimeZonePickerDialog +import de.jeanlucmakiola.calendula.ui.common.icuTimeZoneRegion import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors import de.jeanlucmakiola.calendula.ui.common.eventFill import de.jeanlucmakiola.floret.components.DialogAmountField @@ -741,7 +742,7 @@ private fun EventEditContent( modifier = Modifier.fillMaxWidth(), ) { val pinned = remember(form.timezone, locale) { - form.timezone?.let { timeZoneOptionOf(it, locale) } + form.timezone?.let { timeZoneOptionOf(it, locale, regionOf = ::icuTimeZoneRegion) } } Column(modifier = Modifier.weight(1f)) { Text(