fix(timezone): show the IANA id + abbreviation, not the long localized name

"Central European Time (Europe/Berlin)" is too wide — it made the zone
field wrap and stretch. Lead with the id ("Europe/Berlin") and follow it
with the abbreviation instead: "CEST · GMT+02:00" in the picker and edit
card, "CET · 8:00 AM – 9:00 AM" on the detail card (abbreviation + the
event's own-zone time).

The abbreviation is resolved DST-aware at the same instant as the offset
(CET vs CEST) via "zzz"; zones with no named abbreviation fall back to a
"GMT+05:30" form, and zoneDescriptor drops the separate offset in that
case so it isn't stated twice. The long localized name is kept on the
option for search only — typing "pacific" still works — but no longer
shown.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-19 10:48:21 +02:00
parent ca01f6e729
commit 01ac61185b
5 changed files with 124 additions and 64 deletions

View File

@@ -3,20 +3,31 @@ package de.jeanlucmakiola.calendula.domain
import java.text.Normalizer import java.text.Normalizer
import java.time.Instant import java.time.Instant
import java.time.ZoneId import java.time.ZoneId
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.time.format.TextStyle import java.time.format.TextStyle
import java.util.Locale import java.util.Locale
/** /**
* One selectable zone, resolved for display: [id] is the IANA id we store in * One selectable zone, resolved for display at a given instant. [id] is the IANA
* `EVENT_TIMEZONE`, [displayName] its localized name, and [offsetMinutes] its * id we store in `EVENT_TIMEZONE` and show as the primary label (via [label]);
* offset *at a given instant* — zones shift with DST, so an offset is only * [shortName] its abbreviation ("CET", "WET", "UTC", or a "GMT+05:30" fallback);
* meaningful next to the moment it was resolved for. * [offsetMinutes] its offset. Both the abbreviation and the offset shift with
* DST, so they're only meaningful next to the moment they were resolved for.
*
* [displayName] is the long localized name ("Central European Time"). It's kept
* for search only — matching on it lets someone type "pacific" — and is *not*
* displayed: spelled out next to the id it made the field too wide to fit.
*/ */
data class TimeZoneOption( data class TimeZoneOption(
val id: String, val id: String,
val displayName: String, val displayName: String,
val shortName: String,
val offsetMinutes: Int, val offsetMinutes: Int,
) { ) {
/** The id as the primary label, underscores undone ("America/New York"). */
val label: String get() = id.replace('_', ' ')
/** The trailing segment of the id ("Europe/Berlin" -> "Berlin"), underscores undone. */ /** The trailing segment of the id ("Europe/Berlin" -> "Berlin"), underscores undone. */
val city: String get() = id.substringAfterLast('/').replace('_', ' ') val city: String get() = id.substringAfterLast('/').replace('_', ' ')
@@ -24,10 +35,20 @@ data class TimeZoneOption(
val region: String get() = id.substringBeforeLast('/', missingDelimiterValue = "") val region: String get() = id.substringBeforeLast('/', missingDelimiterValue = "")
} }
private fun optionFor(zone: ZoneId, locale: Locale, at: Instant) = TimeZoneOption(
id = zone.id,
displayName = zone.getDisplayName(TextStyle.FULL, locale),
// "zzz" resolves the DST-correct abbreviation at [at] (CET vs CEST); zones
// with no named abbreviation fall back to a "GMT+05:30" form, which is
// exactly what we'd want to show them as anyway.
shortName = ZonedDateTime.ofInstant(at, zone)
.format(DateTimeFormatter.ofPattern("zzz", locale)),
offsetMinutes = zone.rules.getOffset(at).totalSeconds / 60,
)
/** /**
* Every zone the JVM knows, localized and resolved at [at]. This is ~600 * Every zone the JVM knows, resolved at [at]. This is ~600 entries, so build it
* entries, so build it once and filter the result rather than rebuilding per * once and filter the result rather than rebuilding per keystroke.
* keystroke.
* *
* Bare three-letter ids ("EST", "CST6CDT") and the legacy SystemV tree are * Bare three-letter ids ("EST", "CST6CDT") and the legacy SystemV tree are
* dropped: they're aliases the tz database keeps for compatibility, they'd * dropped: they're aliases the tz database keeps for compatibility, they'd
@@ -40,14 +61,7 @@ fun timeZoneOptions(
): List<TimeZoneOption> = ZoneId.getAvailableZoneIds() ): List<TimeZoneOption> = ZoneId.getAvailableZoneIds()
.asSequence() .asSequence()
.filter { it.contains('/') && !it.startsWith("SystemV/") } .filter { it.contains('/') && !it.startsWith("SystemV/") }
.map { id -> .map { optionFor(ZoneId.of(it), locale, at) }
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 })) .sortedWith(compareBy({ it.region }, { it.city }))
.toList() .toList()
@@ -62,11 +76,18 @@ fun timeZoneOptionOf(
at: Instant = Instant.now(), at: Instant = Instant.now(),
): TimeZoneOption? { ): TimeZoneOption? {
val zone = runCatching { ZoneId.of(id) }.getOrNull() ?: return null val zone = runCatching { ZoneId.of(id) }.getOrNull() ?: return null
return TimeZoneOption( return optionFor(zone, locale, at)
id = id, }
displayName = zone.getDisplayName(TextStyle.FULL, locale),
offsetMinutes = zone.rules.getOffset(at).totalSeconds / 60, /**
) * 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.
*/
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)}"
} }
/** /**

View File

@@ -35,8 +35,8 @@ import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.TimeZoneOption import de.jeanlucmakiola.calendula.domain.TimeZoneOption
import de.jeanlucmakiola.calendula.domain.filterTimeZones import de.jeanlucmakiola.calendula.domain.filterTimeZones
import de.jeanlucmakiola.calendula.domain.formatGmtOffset
import de.jeanlucmakiola.calendula.domain.timeZoneOptions import de.jeanlucmakiola.calendula.domain.timeZoneOptions
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
import de.jeanlucmakiola.floret.components.FullScreenPicker import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.InlineTextField import de.jeanlucmakiola.floret.components.InlineTextField
@@ -240,8 +240,8 @@ private fun ZoneRow(
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
GroupedRow( GroupedRow(
title = zone.city, title = zone.label,
summary = "${zone.displayName} · ${formatGmtOffset(zone.offsetMinutes)}", summary = zoneDescriptor(zone),
position = position, position = position,
selected = selected, selected = selected,
trailing = if (selected) { trailing = if (selected) {
@@ -253,8 +253,8 @@ private fun ZoneRow(
) )
} }
/** "Central European Time · GMT+02:00" for [zoneId], or the bare id if unknown. */ /** "CET · GMT+01:00" for [zoneId], or the bare id if the catalogue lacks it. */
private fun zoneSummary(zoneId: String, allZones: List<TimeZoneOption>): String = private fun zoneSummary(zoneId: String, allZones: List<TimeZoneOption>): String =
allZones.firstOrNull { it.id == zoneId } allZones.firstOrNull { it.id == zoneId }
?.let { "${it.displayName} · ${formatGmtOffset(it.offsetMinutes)}" } ?.let { zoneDescriptor(it) }
?: zoneId ?: zoneId

View File

@@ -93,6 +93,9 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventStatus import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.Reminder import de.jeanlucmakiola.calendula.domain.Reminder
import de.jeanlucmakiola.calendula.domain.TimeZoneOption
import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
import de.jeanlucmakiola.floret.identity.predictiveBack import de.jeanlucmakiola.floret.identity.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
@@ -108,7 +111,6 @@ import kotlinx.datetime.TimeZone
import java.time.ZoneId import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle import java.time.format.FormatStyle
import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale import java.util.Locale
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant import kotlin.time.Instant
@@ -460,32 +462,37 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
// Same hierarchy as the When card above — the label carries the card, // Same hierarchy as the When card above — the label carries the card,
// the time sits small beneath it. The local time is the one the reader // the time sits small beneath it. The local time is the one the reader
// acts on, so the original must stay quieter than it, not compete. // acts on, so the original must stay quieter than it, not compete.
foreignTimeZoneLabel(detail.eventTimezone, instance.isAllDay, locale)?.let { tzLabel -> val foreignZone = remember(detail.eventTimezone, instance.isAllDay, locale) {
foreignTimeZone(detail.eventTimezone, instance.isAllDay, locale)
}
foreignZone?.let { zoneOption ->
Spacer(Modifier.height(gap)) Spacer(Modifier.height(gap))
DetailCard( DetailCard(
icon = Icons.Default.Public, icon = Icons.Default.Public,
iconContentDescription = stringResource(R.string.event_detail_timezone), iconContentDescription = stringResource(R.string.event_detail_timezone),
) { ) {
Text(text = tzLabel, style = MaterialTheme.typography.titleMedium) // The id is the primary label ("Europe/Berlin"); the long
val originalZone = detail.eventTimezone?.let { tz -> // localized name spelled next to it made the field too wide.
runCatching { TimeZone.of(tz) }.getOrNull() Text(text = zoneOption.label, style = MaterialTheme.typography.titleMedium)
} // Beneath, small: the abbreviation plus the event's own-zone time,
if (originalZone != null) { // e.g. "CET · 8:00 AM 9:00 AM". formatWhen puts the range in the
// formatWhen puts the time range in the secondary half only // secondary half only for a same-day event; one spanning midnight
// for a same-day event; one spanning midnight — which a // — which a cross-zone event easily does — carries the whole span
// cross-zone event easily does — carries the whole span in // in the primary instead, so fall back to it. If the zone can't be
// the primary instead, so fall back to it rather than // read back at all, drop to just the abbreviation + offset.
// dropping the original time entirely. val originalTime = runCatching { TimeZone.of(zoneOption.id) }.getOrNull()
val (primary, secondary) = formatWhen(instance, originalZone, locale) ?.let { formatWhen(instance, it, locale) }
Spacer(Modifier.height(2.dp)) ?.let { (primary, secondary) -> secondary ?: primary }
Text( Spacer(Modifier.height(2.dp))
// The label above already names the zone, so the range Text(
// needs no "in New York" tail to be unambiguous here. text = if (originalTime != null) {
text = secondary ?: primary, "${zoneOption.shortName} · $originalTime"
style = MaterialTheme.typography.bodyMedium, } else {
color = MaterialTheme.colorScheme.onSurfaceVariant, zoneDescriptor(zoneOption)
) },
} style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} }
} }
@@ -777,21 +784,15 @@ private fun attendeeRoleLabel(attendee: Attendee): Int? = when {
private fun reminderLeadText(reminder: Reminder): String = reminderLeadTimeLabel(reminder.minutes) private fun reminderLeadText(reminder: Reminder): String = reminderLeadTimeLabel(reminder.minutes)
/** /**
* A localized label for [tz] (e.g. "Central European Time (Europe/Berlin)"), * [tz] resolved as a [TimeZoneOption], but only when the event is timed and
* but only when the event is timed and pinned to a zone different from the * pinned to a zone different from the device's — the cases where showing it
* device's. Returns null when there's nothing worth showing. * removes ambiguity. Null otherwise (all-day, device-zone, blank, or an id the
* tz database doesn't know).
*/ */
private fun foreignTimeZoneLabel(tz: String?, isAllDay: Boolean, locale: Locale): String? { private fun foreignTimeZone(tz: String?, isAllDay: Boolean, locale: Locale): TimeZoneOption? {
if (isAllDay || tz.isNullOrBlank()) return null if (isAllDay || tz.isNullOrBlank()) return null
val deviceZone = ZoneId.systemDefault().id if (tz == ZoneId.systemDefault().id) return null
if (tz == deviceZone) return null return timeZoneOptionOf(tz, locale)
return try {
val zone = ZoneId.of(tz)
val name = zone.getDisplayName(JavaTextStyle.FULL, locale)
if (name == tz) tz else "$name ($tz)"
} catch (e: Exception) {
tz
}
} }
/** Wrap http(s) URLs in [text] as tappable links tinted [linkColor]. */ /** Wrap http(s) URLs in [text] as tappable links tinted [linkColor]. */

View File

@@ -112,7 +112,7 @@ import de.jeanlucmakiola.calendula.domain.EventAttendee
import de.jeanlucmakiola.calendula.domain.EventColorOption import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventFormField import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.formatGmtOffset import de.jeanlucmakiola.calendula.domain.zoneDescriptor
import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
import de.jeanlucmakiola.calendula.domain.timesIn import de.jeanlucmakiola.calendula.domain.timesIn
import de.jeanlucmakiola.calendula.domain.EventFormProblem import de.jeanlucmakiola.calendula.domain.EventFormProblem
@@ -745,13 +745,12 @@ private fun EventEditContent(
} }
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = pinned?.city text = pinned?.label
?: stringResource(R.string.event_edit_timezone_device), ?: stringResource(R.string.event_edit_timezone_device),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
) )
Text( Text(
text = pinned text = pinned?.let { zoneDescriptor(it) }
?.let { "${it.displayName} · ${formatGmtOffset(it.offsetMinutes)}" }
?: stringResource(R.string.event_edit_timezone_device_summary), ?: stringResource(R.string.event_edit_timezone_device_summary),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,

View File

@@ -30,6 +30,45 @@ class TimeZoneCatalogTest {
assertThat(ny.region).isEqualTo("America") assertThat(ny.region).isEqualTo("America")
} }
@Test
fun `label is the id with underscores undone`() {
assertThat(zones.first { it.id == "America/New_York" }.label)
.isEqualTo("America/New York")
assertThat(zones.first { it.id == "Europe/Berlin" }.label).isEqualTo("Europe/Berlin")
}
@Test
fun `resolved abbreviation is compact, not the long name`() {
// Whatever CLDR gives ("CET"/"CEST" or a "GMT+.." fallback), it must be
// short and space-free — that's the whole point of showing it instead of
// "Central European Time".
val berlin = zones.first { it.id == "Europe/Berlin" }
assertThat(berlin.shortName).doesNotContain(" ")
assertThat(berlin.shortName.length).isLessThan(berlin.displayName.length)
}
@Test
fun `descriptor pairs a named abbreviation with the offset`() {
val option = TimeZoneOption(
id = "Europe/Berlin",
displayName = "Central European Time",
shortName = "CET",
offsetMinutes = 60,
)
assertThat(zoneDescriptor(option)).isEqualTo("CET · GMT+01:00")
}
@Test
fun `descriptor drops the offset when the abbreviation already is one`() {
// Repeating the offset after an offset-shaped abbreviation would just say
// the same thing twice.
fun descriptorFor(shortName: String, offset: Int) = zoneDescriptor(
TimeZoneOption(id = "X/Y", displayName = "", shortName = shortName, offsetMinutes = offset),
)
assertThat(descriptorFor("UTC", 0)).isEqualTo("UTC")
assertThat(descriptorFor("GMT+05:30", 330)).isEqualTo("GMT+05:30")
}
@Test @Test
fun `offset is resolved at the given instant, not the current one`() { fun `offset is resolved at the given instant, not the current one`() {
val berlin = zones.first { it.id == "Europe/Berlin" } val berlin = zones.first { it.id == "Europe/Berlin" }