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 <html lang> 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.
This commit is contained in:
Maurice
2026-10-07 16:34:48 +02:00
parent 461a63fc86
commit 8f02337d45
7 changed files with 46 additions and 13 deletions
+1 -1
View File
@@ -113,7 +113,7 @@ export function TranslationProvider({ children }: TranslationProviderProps) {
const [strings, setStrings] = useState<TranslationStrings>(en)
useEffect(() => {
document.documentElement.lang = language
document.documentElement.lang = getIntlLanguage(language)
document.documentElement.dir = isRtlLanguage(language) ? 'rtl' : 'ltr'
}, [language])
@@ -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' })}
</button>
)
})}
@@ -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
+6 -5
View File
@@ -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
<span className="text-xs font-semibold text-center leading-tight" style={{ color: tp, maxWidth: 90, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{item.name}</span>
{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 <span className="text-[9px] mt-0.5 text-center" style={{ color: tf }}>{label}</span>
})()}
{!item.target_date && item.notes && <span className="text-[9px] mt-0.5 text-center" style={{ color: tf, maxWidth: 90, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{item.notes}</span>}
@@ -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' }}
>
<Check size={9} />
{new Date(item.visited_at).toLocaleDateString(language)}
{new Date(item.visited_at).toLocaleDateString(getIntlLanguage(language))}
</button>
) : (
<span
@@ -619,7 +620,7 @@ function SidebarContent({ data, stats, regionsVisited, countryRegions, onOpenPla
style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))' }}
>
<Check size={9} />
{new Date(item.visited_at).toLocaleDateString(language)}
{new Date(item.visited_at).toLocaleDateString(getIntlLanguage(language))}
</span>
)}
</Tooltip>
@@ -707,7 +708,7 @@ function SidebarContent({ data, stats, regionsVisited, countryRegions, onOpenPla
<div style={{ display: 'flex', gap: 6 }}>
<div style={{ flex: 1 }}>
<CustomSelect value={String(bucketPoiMonth)} onChange={v => 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' }) }))]} />
</div>
<div style={{ flex: 1 }}>
<CustomSelect value={String(bucketPoiYear)} onChange={v => setBucketPoiYear(Number(v))} placeholder={t('atlas.year')} size="sm"
+3 -1
View File
@@ -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),
+23
View File
@@ -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);
}
});
});
+7 -2
View File
@@ -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<Record<string, string>> = { 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';
}