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'; }