feat(timezone): let the picker search by abbreviation

Search matched city, id, and the long localized name but not the
abbreviation, so typing "CEST" found nothing. Match it too: an exact
abbreviation hit ranks just under a city prefix, so typing an abbreviation
gathers every zone that shows it (all the CEST zones at once). It matches
the region-resolved abbreviation — i.e. exactly what the row displays.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-19 11:25:22 +02:00
parent c3ba8ccf64
commit 4ea68adf8b
2 changed files with 24 additions and 5 deletions

View File

@@ -144,7 +144,10 @@ fun zoneDescriptor(option: TimeZoneOption): String {
* 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.
* IANA id gets it first. An exact abbreviation hit ("CEST") ranks just under a
* city prefix, so typing an abbreviation surfaces every zone that shows it
* (all the CEST zones together); the abbreviation is the one resolved for the
* display region, i.e. what the row actually shows.
*/
fun filterTimeZones(options: List<TimeZoneOption>, query: String): List<TimeZoneOption> {
val needle = query.normalizeForSearch()
@@ -154,12 +157,15 @@ fun filterTimeZones(options: List<TimeZoneOption>, query: String): List<TimeZone
val city = option.city.normalizeForSearch()
val id = option.id.normalizeForSearch()
val name = option.displayName.normalizeForSearch()
val abbrev = option.shortName.normalizeForSearch()
val rank = when {
city.startsWith(needle) -> 0
name.startsWith(needle) -> 1
city.contains(needle) -> 2
id.contains(needle) -> 3
name.contains(needle) -> 4
abbrev == needle -> 1
name.startsWith(needle) -> 2
abbrev.startsWith(needle) -> 3
city.contains(needle) -> 4
id.contains(needle) -> 5
name.contains(needle) -> 6
else -> return@mapNotNull null
}
rank to option

View File

@@ -105,6 +105,19 @@ class TimeZoneCatalogTest {
assertThat(filter("europe/berlin").map { it.id }).contains("Europe/Berlin")
}
@Test
fun `query matches the abbreviation, case-insensitively`() {
// "cest" isn't a city, id, or substring of "Central European Summer
// Time", so the only way these match is via the abbreviation.
val hits = filter("cest")
assertThat(hits).isNotEmpty()
assertThat(hits.map { it.id }).contains("Europe/Berlin")
// Every hit genuinely carries that abbreviation — nothing bled in.
assertThat(hits.all { it.shortName.equals("CEST", ignoreCase = true) }).isTrue()
// Case doesn't matter.
assertThat(filter("CEST").map { it.id }).isEqualTo(hits.map { it.id })
}
@Test
fun `a city starting with the query outranks one merely containing it`() {
val ids = filter("york").map { it.id }