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