From 6906c6a36516aafc321bd4c631d7d9adbab0f193 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sat, 5 Sep 2026 05:09:07 +0100 Subject: [PATCH 1/2] fix: commit locale changes after catalog load --- frontend/taskdeck-web/src/i18n/index.ts | 8 +- .../taskdeck-web/src/locales/en/settings.ts | 2 + .../taskdeck-web/src/locales/es/settings.ts | 2 + .../taskdeck-web/src/locales/it/settings.ts | 2 + frontend/taskdeck-web/src/main.ts | 46 +++--- .../taskdeck-web/src/store/localeStore.ts | 132 +++++++++++++----- .../src/tests/i18n/lazyLocales.spec.ts | 25 ++-- .../tests/store/localeStore.failure.spec.ts | 118 +++++++++++++--- .../AppearanceSettingsView.language.spec.ts | 40 +++++- .../src/views/AppearanceSettingsView.vue | 52 ++++++- 10 files changed, 335 insertions(+), 92 deletions(-) diff --git a/frontend/taskdeck-web/src/i18n/index.ts b/frontend/taskdeck-web/src/i18n/index.ts index 210fda99a..899241a2f 100644 --- a/frontend/taskdeck-web/src/i18n/index.ts +++ b/frontend/taskdeck-web/src/i18n/index.ts @@ -23,8 +23,9 @@ import en from '../locales/en' * code-split behind `ensureLocaleMessages()` and fetched the first time the * user selects them, so adding translated surfaces no longer spends the * total-JS budget for users who never leave English. While a catalog is in - * flight (or if its chunk fails to load), the silent-fallback semantics above - * already describe what the user sees: English. + * flight (or if its chunk fails to load), the locale store keeps the last + * committed language active; the low-level runtime still has English as its + * fallback for keys that are not present in the committed catalog. */ export const SUPPORTED_LOCALES = ['en', 'it', 'es'] as const @@ -90,7 +91,8 @@ const loaded = new Set([DEFAULT_LOCALE]) * Fetch and register a locale's catalog, once. Resolves `true` when the * catalog is available (already or newly), `false` when the chunk failed to * load — in which case the failure is forgotten so a later switch retries, - * and the user simply stays on English fallback in the meantime. Never throws. + * and callers can retain their previous committed language in the meantime. + * Never throws. */ export function ensureLocaleMessages(locale: SupportedLocale): Promise { if (loaded.has(locale)) return Promise.resolve(true) diff --git a/frontend/taskdeck-web/src/locales/en/settings.ts b/frontend/taskdeck-web/src/locales/en/settings.ts index b19e3314e..2b664c031 100644 --- a/frontend/taskdeck-web/src/locales/en/settings.ts +++ b/frontend/taskdeck-web/src/locales/en/settings.ts @@ -40,5 +40,7 @@ export default { label: 'Language', hint: 'Taskdeck is being translated one surface at a time. Anything not translated yet stays in English. Options marked "Machine-translated" have not yet been reviewed by a native speaker.', machineTranslated: 'Machine-translated', + loading: 'Loading {locale}…', + loadFailed: "Couldn’t load {locale}. {activeLocale} remains active. Try again.", }, } diff --git a/frontend/taskdeck-web/src/locales/es/settings.ts b/frontend/taskdeck-web/src/locales/es/settings.ts index 63f2a55b2..8a0f4fc5b 100644 --- a/frontend/taskdeck-web/src/locales/es/settings.ts +++ b/frontend/taskdeck-web/src/locales/es/settings.ts @@ -34,5 +34,7 @@ export default { label: 'Idioma', hint: 'Taskdeck se traduce superficie a superficie. Lo que aún no está traducido se queda en inglés. Las opciones marcadas como "Traducción automática" aún no han sido revisadas por un hablante nativo.', machineTranslated: 'Traducción automática', + loading: 'Cargando {locale}…', + loadFailed: 'No se pudo cargar {locale}. Se mantiene {activeLocale}. Inténtalo de nuevo.', }, } diff --git a/frontend/taskdeck-web/src/locales/it/settings.ts b/frontend/taskdeck-web/src/locales/it/settings.ts index 6c9fa6970..14810b40d 100644 --- a/frontend/taskdeck-web/src/locales/it/settings.ts +++ b/frontend/taskdeck-web/src/locales/it/settings.ts @@ -34,5 +34,7 @@ export default { label: 'Lingua', hint: 'Taskdeck viene tradotto una superficie alla volta. Ciò che non è ancora tradotto resta in inglese. Le opzioni contrassegnate come "Traduzione automatica" non sono ancora state riviste da un madrelingua.', machineTranslated: 'Traduzione automatica', + loading: 'Caricamento di {locale}…', + loadFailed: 'Impossibile caricare {locale}. {activeLocale} resta attivo. Riprova.', }, } diff --git a/frontend/taskdeck-web/src/main.ts b/frontend/taskdeck-web/src/main.ts index 0ec22822b..620237ae2 100644 --- a/frontend/taskdeck-web/src/main.ts +++ b/frontend/taskdeck-web/src/main.ts @@ -22,33 +22,35 @@ app.use(pinia) app.use(i18n) app.use(router) -// Restore the persisted language preference (ADR-0054 §7) and push it into the -// i18n runtime + ``. Statically imported and called synchronously on -// purpose: this must happen after `app.use(pinia)` (the store needs an active -// Pinia) and BEFORE `app.mount` below, so the first paint is already in the -// user's language instead of flashing English. A dynamic import would resolve -// after mount and produce exactly that flash. -useLocaleStore(pinia).apply() - // Install global crash-prevention hooks before mount so early errors are // captured. The Vue handler is the top-level backstop for render/lifecycle // errors; the window listeners catch async rejections and non-Vue errors. installVueErrorHandler(app) installWindowErrorListeners() -app.mount('#app') +// Restore the persisted language preference (ADR-0054 §7) before mount. The +// store keeps the committed locale on English until a lazy catalog is ready, +// so waiting here preserves the no-flash guarantee without briefly claiming a +// language whose messages are unavailable. Catalog failure is reported by the +// mounted picker; it must not prevent the app from starting in English. +void useLocaleStore(pinia) + .apply() + .catch(() => undefined) + .finally(() => { + app.mount('#app') -// Initialize telemetry after mount (non-blocking, opt-in). -// This restores user consent from localStorage and fetches server config. -// No events are emitted unless the user has explicitly opted in. -import('./store/telemetryStore').then(({ useTelemetryStore }) => { - const telemetry = useTelemetryStore() - void telemetry.initialize() -}) + // Initialize telemetry after mount (non-blocking, opt-in). + // This restores user consent from localStorage and fetches server config. + // No events are emitted unless the user has explicitly opted in. + import('./store/telemetryStore').then(({ useTelemetryStore }) => { + const telemetry = useTelemetryStore() + void telemetry.initialize() + }) -// Initialize analytics script watcher after mount (non-blocking). -// This watches the telemetry store's analyticsConfig and injects/removes -// the analytics script based on user consent and server configuration. -import('./composables/useAnalyticsScript').then(({ initAnalyticsScriptWatcher }) => { - initAnalyticsScriptWatcher() -}) + // Initialize analytics script watcher after mount (non-blocking). + // This watches the telemetry store's analyticsConfig and injects/removes + // the analytics script based on user consent and server configuration. + import('./composables/useAnalyticsScript').then(({ initAnalyticsScriptWatcher }) => { + initAnalyticsScriptWatcher() + }) + }) diff --git a/frontend/taskdeck-web/src/store/localeStore.ts b/frontend/taskdeck-web/src/store/localeStore.ts index 0021afe57..44d35a527 100644 --- a/frontend/taskdeck-web/src/store/localeStore.ts +++ b/frontend/taskdeck-web/src/store/localeStore.ts @@ -11,10 +11,11 @@ import { /** * Language preference (ADR-0054 §7). * - * Deliberately mirrors `paperThemeStore`: a Pinia store whose value is read - * from and written to `localStorage`, validated on read, defaulted on garbage, - * with an `apply()` action that pushes the value into the runtime — there, a - * class on ``; here, the vue-i18n locale plus ``. + * Deliberately mirrors `paperThemeStore`: a Pinia store whose preferred value + * is read from and written to `localStorage`, validated on read, defaulted on + * garbage, with an `apply()` action that pushes a loaded preference into the + * runtime — there, a class on ``; here, the vue-i18n locale plus + * ``. * * This is a CLIENT DISPLAY preference. It is not sent to the backend and there * is no server-side user-preference row for it. If it ever needs to follow the @@ -46,57 +47,118 @@ function applyLocale(locale: SupportedLocale) { } } +function persistLocale(locale: SupportedLocale) { + try { + if (typeof window !== 'undefined') { + window.localStorage.setItem(STORAGE_KEY, locale) + } + } catch { + // ignore quota / private-mode failures — the in-memory preference still applies + } +} + +// A catalog response is allowed to commit only if it belongs to the most +// recent request. This is deliberately separate from the catalog loader's +// per-locale in-flight deduplication: two different locale requests can be in +// flight at once, and the older one must not overwrite the newer choice. +let latestRequestGeneration = 0 + export const useLocaleStore = defineStore('locale', { state: () => ({ - locale: readStoredLocale() as SupportedLocale, + // `locale` is the committed/displayed locale. The stored preference is a + // desired locale until its catalog has loaded successfully. + locale: DEFAULT_LOCALE as SupportedLocale, + preferredLocale: readStoredLocale() as SupportedLocale, + pendingLocale: null as SupportedLocale | null, + failedLocale: null as SupportedLocale | null, }), getters: { available(): ReadonlyArray { return SUPPORTED_LOCALES }, + isPending: (state): boolean => state.pendingLocale !== null, }, actions: { /** - * Push the current locale into the i18n runtime and ``. - * Idempotent. Flip FIRST: `it`/`es` catalogs are code-split (#1858), and - * until the chunk arrives the silent en-fallback shows English — the same - * thing the user already sees for any not-yet-extracted surface; - * `setLocaleMessage` is reactive, so translations appear when it lands. + * Restore the stored preference before the first app mount. A non-English + * catalog is loaded before the preference is committed, so the first + * mounted render cannot claim a language whose messages are unavailable. * - * If the chunk FAILS, the runtime, ``, and the in-memory store - * value are all reverted to English so the UI never claims a language it - * is not rendering (the flip-first window is bounded by the request; a - * failure is not). The persisted preference is deliberately KEPT: a - * transient failure (offline, stale deployment mid-swap) self-heals on the - * next boot or the next manual switch instead of silently discarding the - * user's choice. The returned promise settles after any revert; callers - * that only care about the switch itself may ignore it. + * If the chunk fails, the committed runtime locale remains usable and the + * persisted preference is deliberately kept so a transient failure + * (offline, stale deployment mid-swap) retries on the next boot or manual + * switch. `failedLocale` lets the mounted picker report that target + * honestly instead of silently falling back. */ apply(): Promise { - const target = this.locale - applyLocale(target) - return ensureLocaleMessages(target).then((ok) => { - const stillWanted = this.locale === target && i18n.global.locale.value === target - if (!ok && stillWanted && target !== DEFAULT_LOCALE) { - this.locale = DEFAULT_LOCALE - applyLocale(DEFAULT_LOCALE) - } - }) + const target = this.preferredLocale + const generation = ++latestRequestGeneration + + this.failedLocale = null + applyLocale(this.locale) + + if (target === this.locale) { + this.pendingLocale = null + return Promise.resolve() + } + + this.pendingLocale = target + return Promise.resolve() + .then(() => ensureLocaleMessages(target)) + .catch(() => false) + .then((ok) => { + if (generation !== latestRequestGeneration) return + + this.pendingLocale = null + if (ok) { + this.locale = target + applyLocale(target) + return + } + + applyLocale(this.locale) + this.failedLocale = target + }) }, setLocale(locale: SupportedLocale): Promise { // Guard the public entry point too: a bad value here would otherwise be // persisted and only rejected on the NEXT read, leaving the running app // on a locale with no catalog. if (!isSupportedLocale(locale)) return Promise.resolve() - this.locale = locale - try { - if (typeof window !== 'undefined') { - window.localStorage.setItem(STORAGE_KEY, locale) - } - } catch { - // ignore quota / private-mode failures — the in-memory switch still applies + + this.preferredLocale = locale + persistLocale(locale) + + const generation = ++latestRequestGeneration + this.failedLocale = null + applyLocale(this.locale) + + // Selecting the committed language again cancels an older pending + // request and restores a truthful, idle picker immediately. + if (locale === this.locale) { + this.pendingLocale = null + return Promise.resolve() } - return this.apply() + + this.pendingLocale = locale + return Promise.resolve() + .then(() => ensureLocaleMessages(locale)) + .catch(() => false) + .then((ok) => { + if (generation !== latestRequestGeneration) return + + this.pendingLocale = null + if (ok) { + this.locale = locale + applyLocale(locale) + return + } + + // Do not infer that a failed chunk left a usable catalog. Keep the + // last committed language and report the requested target to the UI. + applyLocale(this.locale) + this.failedLocale = locale + }) }, }, }) diff --git a/frontend/taskdeck-web/src/tests/i18n/lazyLocales.spec.ts b/frontend/taskdeck-web/src/tests/i18n/lazyLocales.spec.ts index 9b341b816..307e05946 100644 --- a/frontend/taskdeck-web/src/tests/i18n/lazyLocales.spec.ts +++ b/frontend/taskdeck-web/src/tests/i18n/lazyLocales.spec.ts @@ -7,11 +7,11 @@ import { useLocaleStore } from '../../store/localeStore' * * Only `en` is registered at module load; `it`/`es` arrive through * `ensureLocaleMessages()`. The global test setup preloads every catalog (so - * the wider suite can keep flipping locales synchronously), which means this - * spec asserts the CONTRACT of the loader — idempotence, registration, and the - * flip-first switch semantics — not the pre-load empty state. The "en-only in - * the initial chunk" claim itself is a build-graph property, proven by the - * bundle-budget CI gate and the emitted per-locale chunks, not assertable here. + * the wider suite can keep checking translated rendering), which means this + * spec asserts the loader contract plus the atomic switch boundary. The + * "en-only in the initial chunk" claim itself is a build-graph property, + * proven by the bundle-budget CI gate and the emitted per-locale chunks, not + * assertable here. */ describe('lazy locale catalogs (#1858)', () => { @@ -30,17 +30,24 @@ describe('lazy locale catalogs (#1858)', () => { expect(second).toBe(true) }) - it('setLocale flips the runtime locale synchronously and returns the catalog promise', async () => { + it('keeps the committed locale until the catalog promise settles', async () => { const store = useLocaleStore() const pending = store.setLocale('es') - // Flip-first: the locale is live before the catalog promise settles, so a - // slow chunk shows English fallback rather than blocking the switch. - expect(i18n.global.locale.value).toBe('es') + // A slow chunk must not make the UI claim Spanish while the old language + // is still rendering. + expect(store.locale).toBe('en') + expect(store.pendingLocale).toBe('es') + expect(i18n.global.locale.value).toBe('en') expect(pending).toBeInstanceOf(Promise) await pending + expect(store.locale).toBe('es') + expect(store.pendingLocale).toBeNull() + expect(i18n.global.locale.value).toBe('es') + await store.setLocale('en') + expect(store.locale).toBe('en') expect(i18n.global.locale.value).toBe('en') }) }) diff --git a/frontend/taskdeck-web/src/tests/store/localeStore.failure.spec.ts b/frontend/taskdeck-web/src/tests/store/localeStore.failure.spec.ts index d7978e39c..ffa091956 100644 --- a/frontend/taskdeck-web/src/tests/store/localeStore.failure.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/localeStore.failure.spec.ts @@ -2,13 +2,13 @@ import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' import { setActivePinia, createPinia } from 'pinia' /** - * Catalog-load failure revert (#1858 review round). + * Catalog-load failure and atomic locale commit (#2003). * - * When a lazy locale chunk fails to load, the flip-first switch must not leave - * the app claiming Italian while rendering English: the runtime locale, - * ``, and the in-memory store value all revert to English. The - * persisted preference is kept on purpose so a transient failure self-heals on - * the next boot — that asymmetry is asserted here too. + * A lazy locale switch must not claim Italian while the Italian catalog is + * still loading or has failed: the previously committed runtime locale, + * ``, and store value remain aligned. The persisted preference is + * kept on purpose so a transient failure self-heals on the next boot — that + * asymmetry is asserted here too. */ vi.mock('../../i18n', async (importOriginal) => { @@ -21,12 +21,24 @@ import { useLocaleStore } from '../../store/localeStore' const STORAGE_KEY = 'td.locale.v1' +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, resolve, reject } +} + describe('localeStore — catalog load failure', () => { beforeEach(() => { setActivePinia(createPinia()) window.localStorage.clear() i18n.global.locale.value = 'en' + document.documentElement.setAttribute('lang', 'en') vi.mocked(ensureLocaleMessages).mockClear() + vi.mocked(ensureLocaleMessages).mockResolvedValue(false) }) afterEach(() => { @@ -34,12 +46,16 @@ describe('localeStore — catalog load failure', () => { document.documentElement.removeAttribute('lang') }) - it('reverts runtime locale, , and store state — but keeps the persisted preference', async () => { + it('keeps the committed locale aligned while a switch fails, and reports the failed target', async () => { const store = useLocaleStore() const pending = store.setLocale('it') - // Flip-first window: the switch is live while the (doomed) load runs. - expect(i18n.global.locale.value).toBe('it') + // Atomic commit: the previous language remains live while the catalog is + // in flight, and the pending target is explicit state for the picker. + expect(store.locale).toBe('en') + expect(store.pendingLocale).toBe('it') + expect(i18n.global.locale.value).toBe('en') + expect(document.documentElement.getAttribute('lang')).toBe('en') await pending @@ -47,22 +63,92 @@ describe('localeStore — catalog load failure', () => { expect(i18n.global.locale.value).toBe('en') expect(document.documentElement.getAttribute('lang')).toBe('en') expect(store.locale).toBe('en') + expect(store.pendingLocale).toBeNull() + expect(store.failedLocale).toBe('it') // Kept: a transient failure retries from storage on the next boot. expect(window.localStorage.getItem(STORAGE_KEY)).toBe('it') }) - it('does not fight a newer switch that happened while the load was failing', async () => { + it('restores a stored locale atomically during startup when its catalog fails', async () => { + window.localStorage.setItem(STORAGE_KEY, 'it') + setActivePinia(createPinia()) + + const store = useLocaleStore() + const pending = store.apply() + + expect(store.locale).toBe('en') + expect(store.pendingLocale).toBe('it') + expect(i18n.global.locale.value).toBe('en') + expect(document.documentElement.getAttribute('lang')).toBe('en') + + await pending + + expect(store.locale).toBe('en') + expect(store.pendingLocale).toBeNull() + expect(store.failedLocale).toBe('it') + expect(i18n.global.locale.value).toBe('en') + expect(document.documentElement.getAttribute('lang')).toBe('en') + }) + + it('lets the latest request win when an obsolete catalog resolves first', async () => { const store = useLocaleStore() + const italian = deferred() + const spanish = deferred() + + vi.mocked(ensureLocaleMessages).mockImplementation((locale) => + locale === 'it' ? italian.promise : spanish.promise, + ) const first = store.setLocale('it') const second = store.setLocale('es') - await Promise.all([first, second]) - // The failed 'it' load must not revert the meanwhile-selected 'es'... which - // itself failed too, so the final state is the reverted default — but via - // the 'es' revert, never a stale 'it' writer. Either way the invariant - // holds: runtime and store agree, and they are a supported value. - expect(store.locale).toBe(i18n.global.locale.value) expect(store.locale).toBe('en') + expect(store.pendingLocale).toBe('es') + expect(i18n.global.locale.value).toBe('en') + + italian.resolve(true) + await first + + // The obsolete success must not commit Italian or clear Spanish's + // pending state. + expect(store.locale).toBe('en') + expect(store.pendingLocale).toBe('es') + expect(i18n.global.locale.value).toBe('en') + + spanish.resolve(true) + await second + + expect(store.locale).toBe('es') + expect(store.pendingLocale).toBeNull() + expect(store.failedLocale).toBeNull() + expect(i18n.global.locale.value).toBe('es') + expect(document.documentElement.getAttribute('lang')).toBe('es') + }) + + it('does not let an obsolete failure undo a newer successful switch', async () => { + const store = useLocaleStore() + const italian = deferred() + const spanish = deferred() + + vi.mocked(ensureLocaleMessages).mockImplementation((locale) => + locale === 'it' ? italian.promise : spanish.promise, + ) + + const first = store.setLocale('it') + const second = store.setLocale('es') + + spanish.resolve(true) + await second + expect(store.locale).toBe('es') + expect(store.failedLocale).toBeNull() + + italian.resolve(false) + await first + + expect(store.locale).toBe('es') + expect(store.pendingLocale).toBeNull() + expect(store.failedLocale).toBeNull() + expect(i18n.global.locale.value).toBe('es') + expect(document.documentElement.getAttribute('lang')).toBe('es') }) }) diff --git a/frontend/taskdeck-web/src/tests/views/AppearanceSettingsView.language.spec.ts b/frontend/taskdeck-web/src/tests/views/AppearanceSettingsView.language.spec.ts index a88b316ac..f472fab8c 100644 --- a/frontend/taskdeck-web/src/tests/views/AppearanceSettingsView.language.spec.ts +++ b/frontend/taskdeck-web/src/tests/views/AppearanceSettingsView.language.spec.ts @@ -1,5 +1,5 @@ import { beforeEach, afterEach, describe, expect, it } from 'vitest' -import { mount } from '@vue/test-utils' +import { flushPromises, mount } from '@vue/test-utils' import { setActivePinia, createPinia } from 'pinia' import AppearanceSettingsView from '../../views/AppearanceSettingsView.vue' import { useLocaleStore } from '../../store/localeStore' @@ -66,11 +66,41 @@ describe('AppearanceSettingsView — language', () => { expect(useLocaleStore().locale).toBe('en') }) + it('shows a pending status without moving the committed selection', () => { + const store = useLocaleStore() + store.pendingLocale = 'it' + + const wrapper = mount(AppearanceSettingsView) + const group = wrapper.find('[data-testid="appearance-language"] [role="group"]') + const status = wrapper.find('[data-testid="appearance-language-status"]') + + expect(group.attributes('aria-busy')).toBe('true') + expect(localeButton(wrapper, 'en').attributes('aria-pressed')).toBe('true') + expect(localeButton(wrapper, 'it').attributes('aria-pressed')).toBe('false') + expect(status.attributes('role')).toBe('status') + expect(status.text()).toBe('Loading Italiano…') + expect(wrapper.findAll('[data-locale]:disabled')).toHaveLength(0) + }) + + it('names a failed target and the language that remains active', () => { + const store = useLocaleStore() + store.failedLocale = 'it' + + const wrapper = mount(AppearanceSettingsView) + const status = wrapper.find('[data-testid="appearance-language-status"]') + + expect(status.attributes('role')).toBe('alert') + expect(status.text()).toBe("Couldn’t load Italiano. English remains active. Try again.") + expect(localeButton(wrapper, 'en').attributes('aria-pressed')).toBe('true') + expect(localeButton(wrapper, 'it').attributes('aria-pressed')).toBe('false') + }) + it('selecting a language persists it through the preferences mechanism', async () => { const wrapper = mount(AppearanceSettingsView) const store = useLocaleStore() await localeButton(wrapper, 'it').trigger('click') + await flushPromises() expect(store.locale).toBe('it') expect(window.localStorage.getItem(STORAGE_KEY)).toBe('it') @@ -85,6 +115,7 @@ describe('AppearanceSettingsView — language', () => { expect(wrapper.text()).toContain('Language') await localeButton(wrapper, 'it').trigger('click') + await flushPromises() // The page it lives on is itself re-rendered in Italian. expect(wrapper.text()).toContain('Aspetto') @@ -94,6 +125,7 @@ describe('AppearanceSettingsView — language', () => { expect(wrapper.find('[data-mode="paper"]').text()).toBe('Paper (chiaro)') await localeButton(wrapper, 'es').trigger('click') + await flushPromises() expect(wrapper.text()).toContain('Apariencia') expect(wrapper.text()).toContain('Idioma') }) @@ -102,18 +134,20 @@ describe('AppearanceSettingsView — language', () => { const wrapper = mount(AppearanceSettingsView) await localeButton(wrapper, 'es').trigger('click') + await flushPromises() expect(document.documentElement.getAttribute('lang')).toBe('es') await localeButton(wrapper, 'en').trigger('click') + await flushPromises() expect(document.documentElement.getAttribute('lang')).toBe('en') }) - it('restores a persisted language on the next visit', () => { + it('restores a persisted language on the next visit', async () => { window.localStorage.setItem(STORAGE_KEY, 'es') setActivePinia(createPinia()) const store = useLocaleStore() - store.apply() + await store.apply() expect(store.locale).toBe('es') expect(mount(AppearanceSettingsView).text()).toContain('Apariencia') diff --git a/frontend/taskdeck-web/src/views/AppearanceSettingsView.vue b/frontend/taskdeck-web/src/views/AppearanceSettingsView.vue index 8ee6d47f1..0c9b1a7c9 100644 --- a/frontend/taskdeck-web/src/views/AppearanceSettingsView.vue +++ b/frontend/taskdeck-web/src/views/AppearanceSettingsView.vue @@ -83,8 +83,31 @@ const languageOptions = computed(() => const activeLocale = computed(() => localeStore.locale) function selectLocale(locale: SupportedLocale) { - localeStore.setLocale(locale) + void localeStore.setLocale(locale) } + +const localeStatus = computed(() => { + const pendingLocale = localeStore.pendingLocale + if (pendingLocale) { + return { + kind: 'pending' as const, + message: t('settings.language.loading', { locale: LOCALE_LABELS[pendingLocale] }), + } + } + + const failedLocale = localeStore.failedLocale + if (failedLocale) { + return { + kind: 'error' as const, + message: t('settings.language.loadFailed', { + locale: LOCALE_LABELS[failedLocale], + activeLocale: LOCALE_LABELS[activeLocale.value], + }), + } + } + + return null +})