mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
fix: find time zones by abbreviation and list the exact offset first
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user