From 36ac81322ae6ad53c957007392334a4a330c95ac Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 12 Oct 2026 15:46:17 +0200 Subject: [PATCH] fix(i18n): a session opened in a fresh browser starts in the browser's language wiki/Languages.md has the account's language first, then the browser's. The browser step only ran on the login page, so opening a trip with a live session in a new browser (or after the site data was cleared) showed English to anyone whose account had no language of its own. The app now starts in the browser's language when nothing was chosen in this browser, and keeps it when the account has none. Nothing is written to the account; a language set in the account or picked in the app still wins. The detection moved to its own module so the settings store can use it at startup. Refs #2604 --- client/src/i18n/TranslationContext.tsx | 25 +----------- client/src/i18n/detectBrowserLanguage.ts | 31 +++++++++++++++ client/src/store/settingsStore.test.ts | 50 +++++++++++++++++++++++- client/src/store/settingsStore.ts | 15 +++++-- 4 files changed, 92 insertions(+), 29 deletions(-) create mode 100644 client/src/i18n/detectBrowserLanguage.ts diff --git a/client/src/i18n/TranslationContext.tsx b/client/src/i18n/TranslationContext.tsx index 3791dc0de..0dda99d6c 100644 --- a/client/src/i18n/TranslationContext.tsx +++ b/client/src/i18n/TranslationContext.tsx @@ -52,30 +52,7 @@ const localeLoaders: Record Promise<{ default: Tran // through this module without changing their import path. export { getLocaleForLanguage, getIntlLanguage, isRtlLanguage } -// Detects the user's preferred language from browser/OS settings. -// Returns null if no supported language matches. -export function detectBrowserLanguage(): string | null { - if (typeof navigator === 'undefined') return null - const browserLangs = navigator.languages?.length - ? navigator.languages - : navigator.language ? [navigator.language] : [] - const supported = SUPPORTED_LANGUAGES.map(l => l.value) - - for (const lang of browserLangs) { - const exactMatch = supported.find(s => s.toLowerCase() === lang.toLowerCase()) - if (exactMatch) return exactMatch - - // pt-BR has no exact match (our code is 'br'), so map it explicitly. - // pt-PT and bare 'pt' are NOT mapped — they fall through to null. - if (lang.toLowerCase() === 'pt-br') return 'br' - - const prefix = lang.split('-')[0]?.toLowerCase() - const prefixMatch = supported.find(s => s.toLowerCase() === prefix) - if (prefixMatch) return prefixMatch - } - - return null -} +export { detectBrowserLanguage } from './detectBrowserLanguage' interface TranslationContextValue { t: (key: string, params?: Record) => string diff --git a/client/src/i18n/detectBrowserLanguage.ts b/client/src/i18n/detectBrowserLanguage.ts new file mode 100644 index 000000000..f494d8523 --- /dev/null +++ b/client/src/i18n/detectBrowserLanguage.ts @@ -0,0 +1,31 @@ +import { SUPPORTED_LANGUAGES } from '@trek/shared'; + +// Its own module so the settings store can run it at startup without importing +// the translation context, which imports the store. + +// Detects the user's preferred language from browser/OS settings. +// Returns null if no supported language matches. +export function detectBrowserLanguage(): string | null { + if (typeof navigator === 'undefined') return null; + const browserLangs = navigator.languages?.length + ? navigator.languages + : navigator.language + ? [navigator.language] + : []; + const supported = SUPPORTED_LANGUAGES.map((l) => l.value); + + for (const lang of browserLangs) { + const exactMatch = supported.find((s) => s.toLowerCase() === lang.toLowerCase()); + if (exactMatch) return exactMatch; + + // pt-BR has no exact match (our code is 'br'), so map it explicitly. + // pt-PT and bare 'pt' are NOT mapped — they fall through to null. + if (lang.toLowerCase() === 'pt-br') return 'br'; + + const prefix = lang.split('-')[0]?.toLowerCase(); + const prefixMatch = supported.find((s) => s.toLowerCase() === prefix); + if (prefixMatch) return prefixMatch; + } + + return null; +} diff --git a/client/src/store/settingsStore.test.ts b/client/src/store/settingsStore.test.ts index 5e7ef67ba..072d686f6 100644 --- a/client/src/store/settingsStore.test.ts +++ b/client/src/store/settingsStore.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest' +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { DEFAULT_SETTINGS, forgetServerLanguage, useSettingsStore } from './settingsStore' import { settingsApi } from '../api/client' import { clearTileCache } from '../sync/tilePrefetcher' @@ -160,3 +160,51 @@ describe('offline language fallback', () => { expect(localStorage.getItem('app_language')).toBe('de') }) }) + +// wiki/Languages.md: the account's language, then the browser's, then English +// (the server default is the login page's, as before). The browser step only ran there, +// so opening a trip with a live session in a fresh browser started in English (#2604). +describe('language detection with a session', () => { + const languages = Object.getOwnPropertyDescriptor(window.navigator, 'languages') + const speak = (...langs: string[]) => + Object.defineProperty(window.navigator, 'languages', { value: langs, configurable: true }) + + beforeEach(() => { + localStorage.removeItem('app_language') + localStorage.removeItem('app_language_server') + useSettingsStore.setState({ settings: { ...DEFAULT_SETTINGS, language: 'en' } }) + }) + afterEach(() => { + if (languages) Object.defineProperty(window.navigator, 'languages', languages) + else delete (window.navigator as { languages?: unknown }).languages + }) + + it('SETTINGS-LANG-008: a cold start with nothing stored boots in the browser language', async () => { + speak('ko-KR', 'en') + vi.resetModules() + const fresh = await import('./settingsStore') + expect(fresh.DEFAULT_SETTINGS.language).toBe('ko') + }) + + it('SETTINGS-LANG-009: an account without a language takes the browser language, nothing is saved', async () => { + speak('ko-KR') + vi.mocked(settingsApi.get).mockResolvedValue({ settings: {} } as never) + await useSettingsStore.getState().loadSettings() + expect(useSettingsStore.getState().settings.language).toBe('ko') + expect(localStorage.getItem('app_language')).toBeNull() + expect(settingsApi.set).not.toHaveBeenCalledWith('language', expect.anything()) + }) + + it('SETTINGS-LANG-011: the account language and an explicit choice both outrank the browser', async () => { + speak('ko-KR') + vi.mocked(settingsApi.get).mockResolvedValue({ settings: { language: 'fr' } } as never) + await useSettingsStore.getState().loadSettings() + expect(useSettingsStore.getState().settings.language).toBe('fr') + + localStorage.setItem('app_language', 'de') + useSettingsStore.setState({ settings: { ...DEFAULT_SETTINGS, language: 'de' } }) + vi.mocked(settingsApi.get).mockResolvedValue({ settings: {} } as never) + await useSettingsStore.getState().loadSettings() + expect(useSettingsStore.getState().settings.language).toBe('de') + }) +}) diff --git a/client/src/store/settingsStore.ts b/client/src/store/settingsStore.ts index 06a2d9c6f..da14f88f4 100644 --- a/client/src/store/settingsStore.ts +++ b/client/src/store/settingsStore.ts @@ -4,6 +4,7 @@ import type { Settings } from '../types' import { DEFAULT_APPEARANCE, DEFAULT_WEEK_START } from '@trek/shared' import { getApiErrorMessage } from '../types' import { SUPPORTED_LANGUAGE_CODES } from '../i18n/supportedLanguages' +import { detectBrowserLanguage } from '../i18n/detectBrowserLanguage' import { normalizeTileUrl, stripTileApiKey } from '../utils/tileUrl' import { clearTileCache } from '../sync/tilePrefetcher' import { rememberStartDestination, DEFAULT_START_PAGE, DEFAULT_START_TRIP_TAB } from '../utils/startDestination' @@ -77,10 +78,10 @@ export const DEFAULT_SETTINGS: Settings = { // Empty = no personal display currency, so Costs falls back to the trip's own. default_currency: '', // Explicit in-app choice first, then the mirror of the account's server-side - // language (written by loadSettings below), then English. Without the mirror - // an offline cold start boots in English: the account's language lives on the - // server, and the fetch that would apply it cannot happen. - language: localStorage.getItem('app_language') || localStorage.getItem(SERVER_LANGUAGE_KEY) || 'en', + // language (written by loadSettings below), then the browser's, then English. + // Without the mirror an offline cold start would boot in English: the account's + // language lives on the server, and the fetch that would apply it cannot happen. + language: localStorage.getItem('app_language') || localStorage.getItem(SERVER_LANGUAGE_KEY) || detectBrowserLanguage() || 'en', temperature_unit: 'celsius', distance_unit: 'metric', time_format: '24h', @@ -153,6 +154,12 @@ export const useSettingsStore = create((set, get) => ({ // choice, and the login page's detection chain must keep running for // users who never made one. rememberServerLanguage(incoming) + // An account without a language of its own, and no choice made in this + // browser: the browser's language, as wiki/Languages.md has it. That step + // used to run on the login page only, so a session opened straight onto a + // trip stayed English. For this session only, nothing is saved. + const detected = !incoming.language && !hasStoredLanguage() ? detectBrowserLanguage() : null + if (detected) get().setLanguageTransient(detected) } catch (err: unknown) { // Leave isLoaded false so a transient failure — offline at launch, or a // (docker) server cold-start racing the first request — is retried on