fix: find time zones by abbreviation and list the exact offset first

This commit is contained in:
Linus Rath
2026-11-04 12:04:32 +01:00
parent 17350c45ca
commit 4333cc4080
5 changed files with 164 additions and 5 deletions
@@ -73,4 +73,27 @@ describe('time zone setting', () => {
fireEvent.change(input, { target: { value: '+05:45' } });
expect(optionTexts()).toEqual([expect.stringMatching(/^Kathmandu/)]);
});
// settings-p2-20: abbreviations and an exact offset find their zones first.
it('finds zones by abbreviation', () => {
const input = openTimeZones();
fireEvent.change(input, { target: { value: 'CET' } });
const found = optionTexts();
expect(found.length).toBeGreaterThan(0);
expect(found).toEqual(expect.arrayContaining([expect.stringMatching(/^Berlin/), expect.stringMatching(/^Paris/)]));
fireEvent.change(input, { target: { value: 'pst' } });
expect(optionTexts()[0]).toMatch(/^Los Angeles/);
});
it('lists the exact offset before offsets that start with the same digits', () => {
const input = openTimeZones();
fireEvent.change(input, { target: { value: 'GMT+1' } });
const found = optionTexts();
const offsetOf = (text: string) => /GMT[+-]\d{2}:\d{2}/.exec(text)?.[0];
const first = offsetOf(found[0]);
// Whatever +1 is today (winter or summer time), it's listed first.
expect(first).toMatch(/^GMT\+01:00$/);
const firstOther = found.findIndex((text) => offsetOf(text) !== first);
expect(found.slice(firstOther).every((text) => offsetOf(text) !== 'GMT+01:00')).toBe(true);
});
});
+12 -3
View File
@@ -1,10 +1,10 @@
"use client";
import { useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslations } from 'next-intl';
import { SearchableSelect, type SearchableOption } from '@/components/ui/searchable-select';
import { AUTO_TIME_ZONE, getBrowserTimeZone, listTimeZones } from '@/lib/timezone';
import { timeZoneChoices, timeZoneCity, type TimeZoneChoice } from '@/lib/time-zone-choices';
import { timeZoneChoices, timeZoneCity, timeZoneSearchRank, type TimeZoneChoice } from '@/lib/time-zone-choices';
import { getEffectiveLocale } from '@/i18n/detect-locale';
/**
@@ -25,11 +25,13 @@ export function TimeZonePicker({ value, onChange }: { value: string; onChange: (
setZones(listTimeZones());
}, []);
const offsets = useMemo(() => new Map<string, number>(), []);
const options = useMemo<SearchableOption[]>(() => {
// A zone synced from another browser (newer tz database) may be missing
// from this runtime's list; keep it selectable.
const extra = value !== AUTO_TIME_ZONE ? [value] : [];
const { common, all } = timeZoneChoices(zones, { locale: getEffectiveLocale(), extra });
for (const choice of all) offsets.set(choice.value, choice.offsetMinutes);
const toOption = (group: string) => (choice: TimeZoneChoice): SearchableOption => ({
value: choice.value,
label: choice.city,
@@ -42,7 +44,13 @@ export function TimeZonePicker({ value, onChange }: { value: string; onChange: (
...common.map(toOption(tz('time_zone_common'))),
...all.map(toOption(tz('time_zone_all'))),
];
}, [zones, value, browserTimeZone, t, tz]);
}, [zones, value, browserTimeZone, t, tz, offsets]);
// "CET" and "GMT+1" find their zones first (settings-p2-20).
const rank = useCallback(
(query: string, option: SearchableOption, wordsMatch: boolean) =>
timeZoneSearchRank(query, { value: option.value, offsetMinutes: offsets.get(option.value) }, wordsMatch),
[offsets],
);
const current = options.find((option) => option.value === value);
return (
@@ -52,6 +60,7 @@ export function TimeZonePicker({ value, onChange }: { value: string; onChange: (
value={value}
options={options}
onSelect={onChange}
rank={rank}
ariaLabel={`${t('time_zone.label')}: ${current?.label ?? timeZoneCity(value)}`}
searchLabel={tz('time_zone_search')}
noMatchLabel={tz('time_zone_no_match')}
+21 -1
View File
@@ -33,6 +33,13 @@ interface SearchableSelectProps {
renderValue?: (option: SearchableOption | undefined) => ReactNode;
className?: string;
menuClassName?: string;
/**
* Orders the results, lower first, and can add or drop options:
* `wordsMatch` says whether every typed word is in the option's text.
* null leaves the option out. Without it, the matching options keep
* their order.
*/
rank?: (query: string, option: SearchableOption, wordsMatch: boolean) => number | null;
}
/**
@@ -50,6 +57,7 @@ export function SearchableSelect({
renderValue,
className,
menuClassName,
rank,
}: SearchableSelectProps) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
@@ -74,12 +82,24 @@ export function SearchableSelect({
const words = needle.split(/\s+/);
// An option listed in two groups shows once in the results.
const seen = new Set<string>();
if (rank) {
const ranked: { entry: (typeof entries)[number]; rank: number }[] = [];
for (const e of entries) {
if (seen.has(e.value)) continue;
const r = rank(query, e, words.every((w) => e.haystack.includes(w)));
if (r === null) continue;
seen.add(e.value);
ranked.push({ entry: e, rank: r });
}
// Array sort is stable: equal ranks keep the list's order.
return ranked.sort((a, b) => a.rank - b.rank).map((r) => r.entry);
}
return entries.filter((e) => {
if (seen.has(e.value) || !words.every((w) => e.haystack.includes(w))) return false;
seen.add(e.value);
return true;
});
}, [entries, query]);
}, [entries, query, rank]);
const close = (refocus: boolean) => {
setOpen(false);
+21 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { timeZoneChoices, timeZoneCity } from '../time-zone-choices';
import { parseTypedOffset, timeZoneChoices, timeZoneCity, timeZoneSearchRank } from '../time-zone-choices';
const JAN = new Date(Date.UTC(2026, 0, 15, 12));
@@ -38,3 +38,23 @@ describe('time zone choices', () => {
expect(all.map((c) => c.value)).toContain('America/Ciudad_Juarez');
});
});
describe('time zone search', () => {
it('reads offsets as people type them', () => {
expect(parseTypedOffset('GMT+1')).toBe(60);
expect(parseTypedOffset('utc-5')).toBe(-300);
expect(parseTypedOffset('+05:30')).toBe(330);
expect(parseTypedOffset('-0800')).toBe(-480);
expect(parseTypedOffset('Berlin')).toBeNull();
expect(parseTypedOffset('GMT')).toBeNull();
});
it('puts the zones of an abbreviation and the exact offset first', () => {
expect(timeZoneSearchRank('CET', { value: 'Europe/Berlin', offsetMinutes: 60 }, false)).toBe(0);
expect(timeZoneSearchRank('cest', { value: 'Europe/Paris', offsetMinutes: 120 }, false)).toBe(0);
expect(timeZoneSearchRank('CET', { value: 'Asia/Tokyo', offsetMinutes: 540 }, false)).toBeNull();
expect(timeZoneSearchRank('GMT+1', { value: 'Europe/Berlin', offsetMinutes: 60 }, true)).toBe(0);
expect(timeZoneSearchRank('GMT+1', { value: 'Australia/Sydney', offsetMinutes: 660 }, true)).toBe(2);
expect(timeZoneSearchRank('Berlin', { value: 'Europe/Berlin', offsetMinutes: 60 }, true)).toBe(1);
});
});
+87
View File
@@ -134,3 +134,90 @@ export function timeZoneChoices(
}
return { common, all };
}
// Abbreviations people type for a zone, with the zones they stand for. Both
// the winter and the summer name, since people type either all year.
const CET = ['Europe/Berlin', 'Europe/Paris', 'Europe/Madrid', 'Europe/Rome', 'Europe/Amsterdam', 'Europe/Brussels',
'Europe/Vienna', 'Europe/Zurich', 'Europe/Stockholm', 'Europe/Oslo', 'Europe/Copenhagen', 'Europe/Warsaw',
'Europe/Prague', 'Europe/Budapest', 'Europe/Belgrade', 'Europe/Luxembourg'];
const EET = ['Europe/Athens', 'Europe/Helsinki', 'Europe/Kyiv', 'Europe/Kiev', 'Europe/Bucharest', 'Europe/Sofia',
'Europe/Riga', 'Europe/Tallinn', 'Europe/Vilnius', 'Africa/Cairo'];
const WET = ['Europe/Lisbon', 'Atlantic/Canary', 'Atlantic/Madeira', 'Atlantic/Faroe', 'Atlantic/Faeroe'];
const US_EAST = ['America/New_York', 'America/Toronto', 'America/Detroit', 'America/Indiana/Indianapolis'];
const US_CENTRAL = ['America/Chicago', 'America/Winnipeg', 'America/Mexico_City'];
const US_MOUNTAIN = ['America/Denver', 'America/Phoenix', 'America/Edmonton', 'America/Boise'];
const US_PACIFIC = ['America/Los_Angeles', 'America/Vancouver', 'America/Tijuana'];
const AU_EAST = ['Australia/Sydney', 'Australia/Melbourne', 'Australia/Brisbane', 'Australia/Hobart', 'Australia/Canberra'];
const UK = ['Europe/London', 'Europe/Dublin'];
export const TIME_ZONE_ABBREVIATIONS: Record<string, readonly string[]> = {
UTC: ['UTC', 'Etc/UTC'],
GMT: ['UTC', 'Etc/UTC', ...UK],
BST: UK,
IST: ['Asia/Kolkata', 'Asia/Calcutta'],
CET, CEST: CET, MEZ: CET, MESZ: CET,
EET, EEST: EET,
WET, WEST: WET,
MSK: ['Europe/Moscow'],
EST: US_EAST, EDT: US_EAST, ET: US_EAST,
CST: [...US_CENTRAL, 'Asia/Shanghai'], CDT: US_CENTRAL, CT: US_CENTRAL,
MST: US_MOUNTAIN, MDT: US_MOUNTAIN, MT: US_MOUNTAIN,
PST: US_PACIFIC, PDT: US_PACIFIC, PT: US_PACIFIC,
AKST: ['America/Anchorage'], AKDT: ['America/Anchorage'],
HST: ['Pacific/Honolulu'],
JST: ['Asia/Tokyo'],
KST: ['Asia/Seoul'],
HKT: ['Asia/Hong_Kong'],
SGT: ['Asia/Singapore'],
AEST: AU_EAST, AEDT: AU_EAST,
ACST: ['Australia/Adelaide', 'Australia/Darwin'], ACDT: ['Australia/Adelaide'],
AWST: ['Australia/Perth'],
NZST: ['Pacific/Auckland'], NZDT: ['Pacific/Auckland'],
BRT: ['America/Sao_Paulo'],
ART: ['America/Argentina/Buenos_Aires', 'America/Buenos_Aires'],
SAST: ['Africa/Johannesburg'],
WAT: ['Africa/Lagos'],
EAT: ['Africa/Nairobi'],
PKT: ['Asia/Karachi'],
WIB: ['Asia/Jakarta'],
ICT: ['Asia/Bangkok', 'Asia/Ho_Chi_Minh', 'Asia/Saigon'],
PHT: ['Asia/Manila'],
GST: ['Asia/Dubai'],
};
/**
* An offset typed in the search ("GMT+1", "UTC-5", "+05:30", "-0800"), in
* minutes east of UTC; null when the text isn't one. "GMT" alone is UTC.
*/
export function parseTypedOffset(text: string): number | null {
const m = /^(?:gmt|utc)?\s*([+\-−])\s*(\d{1,2})(?::?(\d{2}))?$/i.exec(text.trim());
if (!m) return null;
const hours = Number(m[2]);
const minutes = m[3] ? Number(m[3]) : 0;
if (hours > 14 || minutes > 59) return null;
return (m[1] === '+' ? 1 : -1) * (hours * 60 + minutes);
}
/**
* Where a zone goes in the search results, lower first; null leaves it out.
* An abbreviation ("CET") or an exact offset ("GMT+1") finds its zones
* first; anything the words match comes after. Without this, "GMT+1" listed
* GMT+11 and GMT+10 before GMT+01:00, and "CET" found nothing.
*/
export function timeZoneSearchRank(
query: string,
zone: { value: string; offsetMinutes?: number },
wordsMatch: boolean,
): number | null {
const typed = query.trim();
const zones = TIME_ZONE_ABBREVIATIONS[typed.toUpperCase()];
if (zones?.includes(zone.value)) return 0;
const offset = parseTypedOffset(typed);
if (offset !== null && zone.offsetMinutes !== undefined) {
if (zone.offsetMinutes === offset) return 0;
// "+1" also finds "+10", but after the zones at +01:00.
return wordsMatch ? 2 : null;
}
return wordsMatch ? 1 : null;
}