diff --git a/components/settings/__tests__/time-zone-setting.test.tsx b/components/settings/__tests__/time-zone-setting.test.tsx index 410f04fc9..f7e15b859 100644 --- a/components/settings/__tests__/time-zone-setting.test.tsx +++ b/components/settings/__tests__/time-zone-setting.test.tsx @@ -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); + }); }); diff --git a/components/settings/time-zone-picker.tsx b/components/settings/time-zone-picker.tsx index fa34fedfd..cf772ea58 100644 --- a/components/settings/time-zone-picker.tsx +++ b/components/settings/time-zone-picker.tsx @@ -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(), []); const options = useMemo(() => { // 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')} diff --git a/components/ui/searchable-select.tsx b/components/ui/searchable-select.tsx index 635ac3885..8eea60a78 100644 --- a/components/ui/searchable-select.tsx +++ b/components/ui/searchable-select.tsx @@ -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(); + 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); diff --git a/lib/__tests__/time-zone-choices.test.ts b/lib/__tests__/time-zone-choices.test.ts index efdbea026..ed71fc64d 100644 --- a/lib/__tests__/time-zone-choices.test.ts +++ b/lib/__tests__/time-zone-choices.test.ts @@ -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); + }); +}); diff --git a/lib/time-zone-choices.ts b/lib/time-zone-choices.ts index 69a3c1df8..438eabde3 100644 --- a/lib/time-zone-choices.ts +++ b/lib/time-zone-choices.ts @@ -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 = { + 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; +}