From 8f02337d453b034f1183d1f9e8efae00480471d1 Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 7 Oct 2026 16:34:48 +0200 Subject: [PATCH] fix(i18n): hand Intl and the document the real language tag for br and gr TREK's codes 'br' (Brazilian Portuguese) and 'gr' (Greek) went straight into and into ten Intl calls. 'br' is Breton, so Brazilian users got Breton month names and screen readers announced the page as Breton; 'gr' is no language at all, so Greek users got the default locale. getIntlLanguage maps both, and every one of those sites goes through it. --- client/src/i18n/TranslationContext.tsx | 2 +- .../screens/atlas/MAtlasBucketSheet.tsx | 7 +++--- client/src/mobile/screens/trip/MTripShell.tsx | 3 ++- client/src/pages/AtlasPage.tsx | 11 +++++---- client/src/utils/placeLocality.ts | 4 +++- shared/src/i18n/languages.spec.ts | 23 +++++++++++++++++++ shared/src/i18n/languages.ts | 9 ++++++-- 7 files changed, 46 insertions(+), 13 deletions(-) create mode 100644 shared/src/i18n/languages.spec.ts diff --git a/client/src/i18n/TranslationContext.tsx b/client/src/i18n/TranslationContext.tsx index 32c4973b2..492bd7043 100644 --- a/client/src/i18n/TranslationContext.tsx +++ b/client/src/i18n/TranslationContext.tsx @@ -113,7 +113,7 @@ export function TranslationProvider({ children }: TranslationProviderProps) { const [strings, setStrings] = useState(en) useEffect(() => { - document.documentElement.lang = language + document.documentElement.lang = getIntlLanguage(language) document.documentElement.dir = isRtlLanguage(language) ? 'rtl' : 'ltr' }, [language]) diff --git a/client/src/mobile/screens/atlas/MAtlasBucketSheet.tsx b/client/src/mobile/screens/atlas/MAtlasBucketSheet.tsx index 15ad2865e..09c354c5a 100644 --- a/client/src/mobile/screens/atlas/MAtlasBucketSheet.tsx +++ b/client/src/mobile/screens/atlas/MAtlasBucketSheet.tsx @@ -5,6 +5,7 @@ import MIconBtn from '../../components/MIconBtn' import { A2_TO_A3 } from '../../../pages/atlas/atlasModel' import type { BucketItem } from '../../../pages/atlas/atlasModel' import type { AtlasController } from './atlasController' +import { getIntlLanguage } from '@trek/shared' interface MAtlasBucketSheetProps { atlas: AtlasController @@ -69,7 +70,7 @@ export default function MAtlasBucketSheet({ atlas, open, onClose }: MAtlasBucket const fmtTarget = (targetDate: string): string => { const [y, m] = targetDate.split('-') - return m ? new Date(Number(y), Number(m) - 1).toLocaleDateString(language, { month: 'short', year: 'numeric' }) : y + return m ? new Date(Number(y), Number(m) - 1).toLocaleDateString(getIntlLanguage(language), { month: 'short', year: 'numeric' }) : y } const itemSub = (item: BucketItem): string => { @@ -257,7 +258,7 @@ function MMonthYearField({ value, onChange, placeholder, language, t }: { const [year, setYear] = useState(selYear ?? new Date().getFullYear()) const display = selYear - ? new Date(selYear, (selMonth ?? 1) - 1).toLocaleDateString(language, { month: 'long', year: 'numeric' }) + ? new Date(selYear, (selMonth ?? 1) - 1).toLocaleDateString(getIntlLanguage(language), { month: 'long', year: 'numeric' }) : '' return ( @@ -288,7 +289,7 @@ function MMonthYearField({ value, onChange, placeholder, language, t }: { onClick={() => { onChange(`${year}-${String(m + 1).padStart(2, '0')}`); setOpen(false) }} className={`rounded-[10px] py-2 text-[0.71875rem] font-bold capitalize ${on ? 'bg-m-act text-m-actfg' : 'bg-[color:var(--m-ic)] text-m-ink'}`} > - {new Date(2000, m, 1).toLocaleDateString(language, { month: 'short' })} + {new Date(2000, m, 1).toLocaleDateString(getIntlLanguage(language), { month: 'short' })} ) })} diff --git a/client/src/mobile/screens/trip/MTripShell.tsx b/client/src/mobile/screens/trip/MTripShell.tsx index 96d5746f5..f264c0139 100644 --- a/client/src/mobile/screens/trip/MTripShell.tsx +++ b/client/src/mobile/screens/trip/MTripShell.tsx @@ -24,6 +24,7 @@ import { useAuthStore } from '../../../store/authStore' import { canManageDocSync } from '../../../components/Files/docsync/useDocSync' import { useDocSyncOffered } from '../../../components/Files/docsync/useDocSyncOffered' import type { Day, Trip } from '../../../types' +import { getIntlLanguage } from '@trek/shared' /** * Mobile trip screen frame. Owns the chrome the design shares across every @@ -190,7 +191,7 @@ function dayChipLabel(day: Day, language: string, fallback: string): string { if (day.date) { const date = new Date(`${day.date.slice(0, 10)}T00:00:00`) if (!Number.isNaN(date.getTime())) { - return `${new Intl.DateTimeFormat(language, { weekday: 'short' }).format(date)} ${date.getDate()}` + return `${new Intl.DateTimeFormat(getIntlLanguage(language), { weekday: 'short' }).format(date)} ${date.getDate()}` } } return fallback diff --git a/client/src/pages/AtlasPage.tsx b/client/src/pages/AtlasPage.tsx index 0901e0772..88d7a9367 100644 --- a/client/src/pages/AtlasPage.tsx +++ b/client/src/pages/AtlasPage.tsx @@ -21,6 +21,7 @@ import { useToast } from '../components/shared/Toast' import { getApiErrorMessage } from '../types' import HelpAnchor from '../components/Help/HelpAnchor' import AtlasCountryPlaces from '../components/Atlas/AtlasCountryPlaces' +import { getIntlLanguage } from '@trek/shared' // Fixed ids rather than useId: the page body stays free of hooks, and there is only one atlas. const COUNTRY_DIALOG_TITLE = 'atlas-country-dialog-title' @@ -421,7 +422,7 @@ function AtlasPageDesktop(): React.ReactElement { placeholder={t('atlas.month')} options={[ { value: '0', label: t('common.none') }, - ...Array.from({ length: 12 }, (_, i) => ({ value: String(i + 1), label: new Date(2000, i).toLocaleString(language, { month: 'long' }) })), + ...Array.from({ length: 12 }, (_, i) => ({ value: String(i + 1), label: new Date(2000, i).toLocaleString(getIntlLanguage(language), { month: 'long' }) })), ]} size="sm" /> @@ -585,7 +586,7 @@ function SidebarContent({ data, stats, regionsVisited, countryRegions, onOpenPla {item.name} {item.target_date && (() => { const [y, m] = item.target_date.split('-') - const label = m ? new Date(Number(y), Number(m) - 1).toLocaleString(language, { month: 'short', year: 'numeric' }) : y + const label = m ? new Date(Number(y), Number(m) - 1).toLocaleString(getIntlLanguage(language), { month: 'short', year: 'numeric' }) : y return {label} })()} {!item.target_date && item.notes && {item.notes}} @@ -611,7 +612,7 @@ function SidebarContent({ data, stats, regionsVisited, countryRegions, onOpenPla style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', background: 'none', border: 'none', padding: 0, cursor: 'pointer', fontFamily: 'inherit' }} > - {new Date(item.visited_at).toLocaleDateString(language)} + {new Date(item.visited_at).toLocaleDateString(getIntlLanguage(language))} ) : ( - {new Date(item.visited_at).toLocaleDateString(language)} + {new Date(item.visited_at).toLocaleDateString(getIntlLanguage(language))} )} @@ -707,7 +708,7 @@ function SidebarContent({ data, stats, regionsVisited, countryRegions, onOpenPla
setBucketPoiMonth(Number(v))} placeholder={t('atlas.month')} size="sm" - options={[{ value: '0', label: '—' }, ...Array.from({ length: 12 }, (_, i) => ({ value: String(i + 1), label: new Date(2000, i).toLocaleString(language, { month: 'short' }) }))]} /> + options={[{ value: '0', label: '—' }, ...Array.from({ length: 12 }, (_, i) => ({ value: String(i + 1), label: new Date(2000, i).toLocaleString(getIntlLanguage(language), { month: 'short' }) }))]} />
setBucketPoiYear(Number(v))} placeholder={t('atlas.year')} size="sm" diff --git a/client/src/utils/placeLocality.ts b/client/src/utils/placeLocality.ts index aad25361a..0dc3952da 100644 --- a/client/src/utils/placeLocality.ts +++ b/client/src/utils/placeLocality.ts @@ -1,3 +1,5 @@ +import { getIntlLanguage } from '@trek/shared' + export interface PlaceLocality { /** The country's name in the reader's language, or as the address spells it. */ country: string | null @@ -34,7 +36,7 @@ function countryFromAddress(address: string | null | undefined): string | null { export function placeLocality(place: LocatedPlace, language: string): PlaceLocality { let country: string | null = null if (place.country_code) { - try { country = new Intl.DisplayNames([language], { type: 'region' }).of(place.country_code.toUpperCase()) ?? null } catch { country = null } + try { country = new Intl.DisplayNames([getIntlLanguage(language)], { type: 'region' }).of(place.country_code.toUpperCase()) ?? null } catch { country = null } } return { country: country ?? countryFromAddress(place.address), diff --git a/shared/src/i18n/languages.spec.ts b/shared/src/i18n/languages.spec.ts new file mode 100644 index 000000000..1b1119b02 --- /dev/null +++ b/shared/src/i18n/languages.spec.ts @@ -0,0 +1,23 @@ +import { getIntlLanguage, SUPPORTED_LANGUAGES } from './languages'; + +import { describe, expect, it } from 'vitest'; + +describe('getIntlLanguage', () => { + it('maps the two codes that are not the BCP-47 tag of their language', () => { + expect(getIntlLanguage('br')).toBe('pt-BR'); + expect(getIntlLanguage('gr')).toBe('el'); + }); + + it('passes every other supported code through and falls back to English', () => { + expect(getIntlLanguage('de')).toBe('de'); + expect(getIntlLanguage('zh-TW')).toBe('zh-TW'); + expect(getIntlLanguage('xx')).toBe('en'); + }); + + it('gives Intl a tag it formats in the right language for every supported code', () => { + for (const { value, locale } of SUPPORTED_LANGUAGES) { + const primary = locale.split('-')[0]; + expect(new Intl.Locale(getIntlLanguage(value)).language, value).toBe(primary); + } + }); +}); diff --git a/shared/src/i18n/languages.ts b/shared/src/i18n/languages.ts index 0f3825a1d..51d5c6c34 100644 --- a/shared/src/i18n/languages.ts +++ b/shared/src/i18n/languages.ts @@ -43,9 +43,14 @@ export function getLocaleForLanguage(language: string): string { return LOCALES[language] ?? LOCALES['en'] ?? 'en-US'; } -// Returns a BCP-47 tag suitable for Intl APIs. +// Two codes TREK uses are not the BCP-47 tag of their language: 'br' is Breton +// and 'gr' is no language at all. +const INTL_LANGUAGE: Partial> = { br: 'pt-BR', gr: 'el' }; + +// Returns a BCP-47 tag suitable for Intl APIs and the document's lang attribute. export function getIntlLanguage(language: string): string { - if (language === 'br') return 'pt-BR'; + const tag = INTL_LANGUAGE[language]; + if (tag) return tag; return SUPPORTED_LANGUAGE_CODES.includes(language) ? language : 'en'; }