From 54831f273a88af4e052efa65526c63ba384e1574 Mon Sep 17 00:00:00 2001 From: Kevin ONeill Date: Fri, 18 Sep 2026 19:23:42 -0500 Subject: [PATCH 1/3] Onboarding goals subtitle: name the selected language The goals step asked "What do you want to use English for?" no matter which language the learner picked: the sentence was hardcoded in all 10 message files. Parameterize it as {language} and pass the selected language's display name from the page (native name via getLanguageByCode, capitalized per formatLanguageName). Add an i18n regression test that fails if any locale stops templating the subtitle. --- frontend/src/app/(auth)/onboarding/page.tsx | 16 +++++++-- .../tests/i18n/onboarding-messages.test.ts | 36 +++++++++++++++++++ messages/de.json | 2 +- messages/en.json | 2 +- messages/es.json | 2 +- messages/fr.json | 2 +- messages/it.json | 2 +- messages/nl.json | 2 +- messages/pl.json | 2 +- messages/pt.json | 2 +- messages/ro.json | 2 +- messages/ru.json | 2 +- 12 files changed, 59 insertions(+), 13 deletions(-) create mode 100644 frontend/tests/i18n/onboarding-messages.test.ts diff --git a/frontend/src/app/(auth)/onboarding/page.tsx b/frontend/src/app/(auth)/onboarding/page.tsx index a0c44a993..09546e0b9 100644 --- a/frontend/src/app/(auth)/onboarding/page.tsx +++ b/frontend/src/app/(auth)/onboarding/page.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from 'react' import { useRouter, useSearchParams } from 'next/navigation' import Image from 'next/image' -import { useTranslations } from 'next-intl' +import { useTranslations, useLocale } from 'next-intl' import { Loader2 } from 'lucide-react' import { apiFetch } from '@/lib/api' import { splitYearlyCta, type BillingInterval } from '@/lib/billing-copy' @@ -12,7 +12,11 @@ import { useAuthStore, isSubscribed, isFreemiumTrialActive } from '@/store/auth' import { useConfigStore } from '@/store/config' import { useLanguageStore } from '@/store/language' import TargetLanguageSelector from '@/components/TargetLanguageSelector' -import { DEFAULT_TARGET_LANGUAGE } from '@/lib/target-languages' +import { + DEFAULT_TARGET_LANGUAGE, + formatLanguageName, + getLanguageByCode, +} from '@/lib/target-languages' const LEARNING_GOALS = [ 'travel', @@ -34,6 +38,7 @@ function getSelectedPlan(plan: string | null): BillingInterval | null { export default function OnboardingPage() { const t = useTranslations('onboarding') const tCommon = useTranslations('common') + const locale = useLocale() const router = useRouter() const searchParams = useSearchParams() const setUser = useAuthStore((s) => s.setUser) @@ -266,7 +271,12 @@ export default function OnboardingPage() { {step === 2 && (

- {t('goals.subtitle')} + {t('goals.subtitle', { + language: formatLanguageName( + getLanguageByCode(targetLanguage)?.name ?? '', + locale + ), + })}

{LEARNING_GOALS.map((goal) => { diff --git a/frontend/tests/i18n/onboarding-messages.test.ts b/frontend/tests/i18n/onboarding-messages.test.ts new file mode 100644 index 000000000..b065376d2 --- /dev/null +++ b/frontend/tests/i18n/onboarding-messages.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import de from '../../../messages/de.json' +import en from '../../../messages/en.json' +import es from '../../../messages/es.json' +import fr from '../../../messages/fr.json' +import itMessages from '../../../messages/it.json' +import nl from '../../../messages/nl.json' +import pl from '../../../messages/pl.json' +import pt from '../../../messages/pt.json' +import ro from '../../../messages/ro.json' +import ru from '../../../messages/ru.json' + +const locales = { + de, + en, + es, + fr, + it: itMessages, + nl, + pl, + pt, + ro, + ru, +} + +describe('onboarding goals subtitle', () => { + it('templates the selected language instead of naming one in every locale', () => { + for (const [locale, messages] of Object.entries(locales)) { + const subtitle = messages.onboarding.goals.subtitle as string + expect(subtitle, locale).toContain('{language}') + expect(subtitle, locale).not.toMatch( + /English|Englisch|inglés|anglais|inglese|Engels|angielskiego|inglês|engleza|английский/i + ) + } + }) +}) diff --git a/messages/de.json b/messages/de.json index 1c53e7f9f..5e3949427 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1295,7 +1295,7 @@ "saveFailed": "Einstellungen konnten nicht gespeichert werden", "goals": { "title": "Deine Lernziele", - "subtitle": "Wozu möchtest du Englisch nutzen? Wähle alles Zutreffende.", + "subtitle": "Wozu möchtest du {language} nutzen? Wähle alles Zutreffende.", "skip": "Überspringen", "travel": "Reisen & Tourismus", "work": "Beruf & Karriere", diff --git a/messages/en.json b/messages/en.json index f04efdeeb..a6cc7c732 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1295,7 +1295,7 @@ "saveFailed": "Failed to save preferences", "goals": { "title": "Your Learning Goals", - "subtitle": "What do you want to use English for? Select all that apply.", + "subtitle": "What do you want to use {language} for? Select all that apply.", "skip": "Skip", "travel": "Travel & Tourism", "work": "Work & Professional", diff --git a/messages/es.json b/messages/es.json index 245892dd2..ff8ad060c 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1295,7 +1295,7 @@ "saveFailed": "Error al guardar las preferencias", "goals": { "title": "Tus objetivos de aprendizaje", - "subtitle": "¿Para qué quieres usar el inglés? Selecciona todos los que apliquen.", + "subtitle": "¿Para qué quieres usar {language}? Selecciona todos los que apliquen.", "skip": "Omitir", "travel": "Viajes y turismo", "work": "Trabajo y carrera", diff --git a/messages/fr.json b/messages/fr.json index 8d1377e9e..40a45d555 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1295,7 +1295,7 @@ "saveFailed": "Échec de l'enregistrement des préférences", "goals": { "title": "Vos objectifs d'apprentissage", - "subtitle": "Pour quoi voulez-vous utiliser l'anglais ? Sélectionnez tout ce qui s'applique.", + "subtitle": "Pour quoi voulez-vous utiliser {language} ? Sélectionnez tout ce qui s'applique.", "skip": "Passer", "travel": "Voyages et tourisme", "work": "Travail et carrière", diff --git a/messages/it.json b/messages/it.json index f7b10c718..b24476484 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1295,7 +1295,7 @@ "saveFailed": "Impossibile salvare le preferenze", "goals": { "title": "I tuoi obiettivi", - "subtitle": "Per cosa vuoi usare l'inglese? Seleziona tutto ciò che si applica.", + "subtitle": "Per cosa vuoi usare {language}? Seleziona tutto ciò che si applica.", "skip": "Salta", "travel": "Viaggi e turismo", "work": "Lavoro e carriera", diff --git a/messages/nl.json b/messages/nl.json index 17cf10a93..6408521bd 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -1295,7 +1295,7 @@ "saveFailed": "Opslaan van voorkeuren mislukt", "goals": { "title": "Jouw leerdoelen", - "subtitle": "Waarvoor wil je Engels gebruiken? Selecteer alles wat van toepassing is.", + "subtitle": "Waarvoor wil je {language} gebruiken? Selecteer alles wat van toepassing is.", "skip": "Overslaan", "travel": "Reizen en toerisme", "work": "Werk en carrière", diff --git a/messages/pl.json b/messages/pl.json index 0047ba281..0e2b26e64 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1295,7 +1295,7 @@ "saveFailed": "Nie udało się zapisać preferencji", "goals": { "title": "Twoje cele nauki", - "subtitle": "Do czego chcesz używać angielskiego? Zaznacz wszystkie, które dotyczą Ciebie.", + "subtitle": "Do czego chcesz używać {language}? Zaznacz wszystkie, które dotyczą Ciebie.", "skip": "Pominąć", "travel": "Podróże i turystyka", "work": "Praca i kariera", diff --git a/messages/pt.json b/messages/pt.json index 527505b8b..880829ee6 100644 --- a/messages/pt.json +++ b/messages/pt.json @@ -1295,7 +1295,7 @@ "saveFailed": "Falha ao salvar as preferências", "goals": { "title": "Seus objetivos de aprendizado", - "subtitle": "Para quê você quer usar o inglês? Selecione tudo que se aplica.", + "subtitle": "Para quê você quer usar {language}? Selecione tudo que se aplica.", "skip": "Pular", "travel": "Viagens e turismo", "work": "Trabalho e carreira", diff --git a/messages/ro.json b/messages/ro.json index e89274a20..e7a612bcc 100644 --- a/messages/ro.json +++ b/messages/ro.json @@ -1295,7 +1295,7 @@ "saveFailed": "Salvarea preferințelor a eșuat", "goals": { "title": "Obiectivele tale de învățare", - "subtitle": "Pentru ce vrei să folosești engleza? Selectează tot ce ți se potrivește.", + "subtitle": "Pentru ce vrei să folosești {language}? Selectează tot ce ți se potrivește.", "skip": "Sari", "travel": "Călătorii și turism", "work": "Muncă și carieră", diff --git a/messages/ru.json b/messages/ru.json index 37e6af6d5..4e4968ba6 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -1295,7 +1295,7 @@ "saveFailed": "Не удалось сохранить настройки", "goals": { "title": "Твои цели обучения", - "subtitle": "Для чего ты хочешь использовать английский? Выбери всё, что подходит.", + "subtitle": "Для чего ты хочешь использовать {language}? Выбери всё, что подходит.", "skip": "Пропустить", "travel": "Путешествия и туризм", "work": "Работа и карьера", From 0fc74478fd93625c32468d8826b6bf000e73e4f1 Mon Sep 17 00:00:00 2001 From: Kevin ONeill Date: Fri, 18 Sep 2026 19:42:45 -0500 Subject: [PATCH 2/3] Correct the onboarding subtitle fix: localized language names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three defects in the first pass, found in audit: - The body claimed the codebase carries no per-locale translations of language names. False: every message file ships a full targetLanguages catalog (de: Spanisch, es: francés...). Use it — the subtitle now names the language exactly as the selector button does, via useTranslations('targetLanguages'), in every UI locale. The native-name detour (getLanguageByCode/formatLanguageName) is removed. - The body claimed omitting CHANGELOG.md matches repo practice. False: AGENTS.md rule 4 requires changelog entries for user-visible changes and every comparable recent change has one. Entry added. - The claimed verification covered only string content, not wiring. The new page-level test renders the real onboarding flow with real next-intl interpolation and asserts the subtitle names the selected language ('What do you want to use Spanish for?...'). Proven to fail against the first-pass code and pass against this one. --- CHANGELOG.md | 1 + frontend/src/app/(auth)/onboarding/page.tsx | 15 +-- .../app/onboarding-goals-subtitle.test.tsx | 127 ++++++++++++++++++ 3 files changed, 132 insertions(+), 11 deletions(-) create mode 100644 frontend/tests/app/onboarding-goals-subtitle.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a0fe5584..30c261a10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Onboarding goals question**: the learning-goals step of onboarding asked "What do you want to use English for?" regardless of the selected target language. The question now names the language the learner actually chose (for example, Español for Spanish) in all ten UI languages. - **Completion slot and level test**: the reserved final plan slot no longer generates a substitute review lesson. `GET /api/study-plan/today` reports an end-of-plan `completion` state (`in_progress`, `ready`, or `taken`), and the dashboard and My Plan route eligible learners to the real level test or show the persisted result. The test unlocks by reaching the final plan day with no lesson from a passed day still pending (skipped lessons keep it locked until completed) and is enforced by the backend (403 before the final position or with pending lessons); legacy final-slot lessons and results are preserved, including plans that stored the slot with the pre-v1.7.0 `level-test` unit id. Once a result exists, pending lessons no longer list the legacy final-slot lesson and `pending_count` stays consistent with `GET /api/study-plan/pending-lessons`, and the dashboard and the level-test result present the next-level step as a retake-assessment action, shown only when the plan's level has a next level. - **Level-test result**: the level completion test result screen renders every label, message, and error through the ten-locale catalogs, and its recommendation copy now reflects the real action: advancing points to retaking the assessment to set up the next plan, while extend and repeat point to reviewing My Plan instead of claiming a plan was already created. - **Study-plan distribution**: new plans spread teaching lessons evenly across all curriculum units, preserve unit order, restart each unit's own lesson-type cycle, and reserve the final position for the completion test. Earlier units no longer consume slots needed by later units. Existing plans keep their stored schedules. diff --git a/frontend/src/app/(auth)/onboarding/page.tsx b/frontend/src/app/(auth)/onboarding/page.tsx index 09546e0b9..1bd9d896d 100644 --- a/frontend/src/app/(auth)/onboarding/page.tsx +++ b/frontend/src/app/(auth)/onboarding/page.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from 'react' import { useRouter, useSearchParams } from 'next/navigation' import Image from 'next/image' -import { useTranslations, useLocale } from 'next-intl' +import { useTranslations } from 'next-intl' import { Loader2 } from 'lucide-react' import { apiFetch } from '@/lib/api' import { splitYearlyCta, type BillingInterval } from '@/lib/billing-copy' @@ -12,11 +12,7 @@ import { useAuthStore, isSubscribed, isFreemiumTrialActive } from '@/store/auth' import { useConfigStore } from '@/store/config' import { useLanguageStore } from '@/store/language' import TargetLanguageSelector from '@/components/TargetLanguageSelector' -import { - DEFAULT_TARGET_LANGUAGE, - formatLanguageName, - getLanguageByCode, -} from '@/lib/target-languages' +import { DEFAULT_TARGET_LANGUAGE } from '@/lib/target-languages' const LEARNING_GOALS = [ 'travel', @@ -38,7 +34,7 @@ function getSelectedPlan(plan: string | null): BillingInterval | null { export default function OnboardingPage() { const t = useTranslations('onboarding') const tCommon = useTranslations('common') - const locale = useLocale() + const tLang = useTranslations('targetLanguages') const router = useRouter() const searchParams = useSearchParams() const setUser = useAuthStore((s) => s.setUser) @@ -272,10 +268,7 @@ export default function OnboardingPage() {

{t('goals.subtitle', { - language: formatLanguageName( - getLanguageByCode(targetLanguage)?.name ?? '', - locale - ), + language: tLang(targetLanguage), })}

diff --git a/frontend/tests/app/onboarding-goals-subtitle.test.tsx b/frontend/tests/app/onboarding-goals-subtitle.test.tsx new file mode 100644 index 000000000..73c466dac --- /dev/null +++ b/frontend/tests/app/onboarding-goals-subtitle.test.tsx @@ -0,0 +1,127 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import React from 'react' +import enMessages from '../../../messages/en.json' + +const { mockApiFetch, mockPush, mockReplace } = vi.hoisted(() => ({ + mockApiFetch: vi.fn(), + mockPush: vi.fn(), + mockReplace: vi.fn(), +})) + +// REAL next-intl message resolution for the onboarding namespace, with real +// {variable} interpolation, so the subtitle wiring is exercised end to end. +vi.mock('next-intl', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useTranslations: (namespace: string) => { + const messages = enMessages as Record + const scope = messages[namespace] as Record + return (key: string, values?: Record) => { + let raw: unknown = scope?.[key] + if (raw === undefined && key.includes('.')) { + raw = key + .split('.') + .reduce< + Record | undefined + >((node, part) => (node && typeof node === 'object' ? (node[part] as Record) : undefined), scope) + } + let text = typeof raw === 'string' ? raw : key + for (const [name, value] of Object.entries(values ?? {})) { + text = text.replaceAll(`{${name}}`, value) + } + return text + } + }, + // useLocale has a real implementation in next-intl but requires a provider; + // supply the UI locale directly instead. + useLocale: () => 'en', + } +}) + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: mockPush, replace: mockReplace }), + useSearchParams: () => new URLSearchParams(), + usePathname: () => '/onboarding', +})) + +vi.mock('@/lib/api', () => ({ + apiFetch: mockApiFetch, +})) + +vi.mock('@/store/auth', () => ({ + useAuthStore: (selector: (state: Record) => unknown) => + selector({ + setUser: vi.fn(), + user: { + id: 1, + username: 'tester', + email: 'tester@example.com', + role: 'admin', + native_language: 'en', + target_language: 'es-ES', + is_active: true, + is_verified: true, + }, + isSubscribed: () => false, + isFreemiumTrialActive: () => false, + }), + isSubscribed: () => false, + isFreemiumTrialActive: () => false, +})) + +vi.mock('@/store/config', () => ({ + useConfigStore: (selector: (state: Record) => unknown) => + selector({ + stripeEnabled: false, + stripeTrialDays: 7, + priceMonthly: '14.95', + priceYearly: '149.50', + load: vi.fn(), + }), +})) + +const mockLanguageState = { + fetchLanguages: vi.fn().mockResolvedValue(undefined), + availableLanguageCodes: ['de-DE', 'en-GB', 'en-US', 'es-ES', 'fr-FR'], +} + +vi.mock('@/store/language', () => { + const useLanguageStore = ( + selector: (state: Record) => unknown + ) => selector(mockLanguageState) + ;(useLanguageStore as unknown as Record).getState = () => + mockLanguageState + return { useLanguageStore } +}) + +import OnboardingPage from '@/app/(auth)/onboarding/page' + +describe('onboarding goals subtitle', () => { + beforeEach(() => { + mockApiFetch.mockReset() + mockPush.mockReset() + }) + + it('names the selected language on the goals step', async () => { + render(React.createElement(OnboardingPage)) + + // step 1: pick Spanish (localized name the selector itself renders), continue + await waitFor(() => + expect(screen.getAllByRole('button').length).toBeGreaterThan(0) + ) + fireEvent.click(screen.getByRole('button', { name: /Spanish/i })) + fireEvent.click(screen.getByRole('button', { name: /^next$/i })) + + // step 2: the subtitle must name the picked language, not English + await waitFor(() => { + expect(screen.getByText(/Select all that apply/)).toBeDefined() + }) + const subtitle = screen.getByText(/Select all that apply/).textContent + expect(subtitle).toBe( + 'What do you want to use Spanish for? Select all that apply.' + ) + expect(subtitle).not.toContain('English') + }) +}) From d9ce189e32c5dc3dbabf7a67c58c74c8250a07b3 Mon Sep 17 00:00:00 2001 From: Kevin ONeill Date: Fri, 18 Sep 2026 19:58:42 -0500 Subject: [PATCH 3/3] Review pass: cite the shipped design in the changelog, trim test scaffolding - The changelog entry still gave the native-name example from the abandoned first pass; it now describes the localized-name behavior that actually ships. - Drop the unused mockReplace handle and tighten two comments in the page test. --- CHANGELOG.md | 2 +- .../tests/app/onboarding-goals-subtitle.test.tsx | 14 ++++++-------- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 30c261a10..863c96e1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- **Onboarding goals question**: the learning-goals step of onboarding asked "What do you want to use English for?" regardless of the selected target language. The question now names the language the learner actually chose (for example, Español for Spanish) in all ten UI languages. +- **Onboarding goals question**: the learning-goals step of onboarding asked "What do you want to use English for?" regardless of the selected target language. The question now names the language the learner actually chose, using the localized language name, in all ten UI languages. - **Completion slot and level test**: the reserved final plan slot no longer generates a substitute review lesson. `GET /api/study-plan/today` reports an end-of-plan `completion` state (`in_progress`, `ready`, or `taken`), and the dashboard and My Plan route eligible learners to the real level test or show the persisted result. The test unlocks by reaching the final plan day with no lesson from a passed day still pending (skipped lessons keep it locked until completed) and is enforced by the backend (403 before the final position or with pending lessons); legacy final-slot lessons and results are preserved, including plans that stored the slot with the pre-v1.7.0 `level-test` unit id. Once a result exists, pending lessons no longer list the legacy final-slot lesson and `pending_count` stays consistent with `GET /api/study-plan/pending-lessons`, and the dashboard and the level-test result present the next-level step as a retake-assessment action, shown only when the plan's level has a next level. - **Level-test result**: the level completion test result screen renders every label, message, and error through the ten-locale catalogs, and its recommendation copy now reflects the real action: advancing points to retaking the assessment to set up the next plan, while extend and repeat point to reviewing My Plan instead of claiming a plan was already created. - **Study-plan distribution**: new plans spread teaching lessons evenly across all curriculum units, preserve unit order, restart each unit's own lesson-type cycle, and reserve the final position for the completion test. Earlier units no longer consume slots needed by later units. Existing plans keep their stored schedules. diff --git a/frontend/tests/app/onboarding-goals-subtitle.test.tsx b/frontend/tests/app/onboarding-goals-subtitle.test.tsx index 73c466dac..05e69bea2 100644 --- a/frontend/tests/app/onboarding-goals-subtitle.test.tsx +++ b/frontend/tests/app/onboarding-goals-subtitle.test.tsx @@ -3,14 +3,13 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import React from 'react' import enMessages from '../../../messages/en.json' -const { mockApiFetch, mockPush, mockReplace } = vi.hoisted(() => ({ +const { mockApiFetch, mockPush } = vi.hoisted(() => ({ mockApiFetch: vi.fn(), mockPush: vi.fn(), - mockReplace: vi.fn(), })) -// REAL next-intl message resolution for the onboarding namespace, with real -// {variable} interpolation, so the subtitle wiring is exercised end to end. +// Resolve real catalog strings with real {variable} interpolation; the +// repo's usual next-intl mock echoes keys and would hide this bug. vi.mock('next-intl', async (importOriginal) => { const actual = await importOriginal() return { @@ -34,14 +33,13 @@ vi.mock('next-intl', async (importOriginal) => { return text } }, - // useLocale has a real implementation in next-intl but requires a provider; - // supply the UI locale directly instead. + // real useLocale needs a provider; pin the UI locale useLocale: () => 'en', } }) vi.mock('next/navigation', () => ({ - useRouter: () => ({ push: mockPush, replace: mockReplace }), + useRouter: () => ({ push: mockPush }), useSearchParams: () => new URLSearchParams(), usePathname: () => '/onboarding', })) @@ -107,7 +105,7 @@ describe('onboarding goals subtitle', () => { it('names the selected language on the goals step', async () => { render(React.createElement(OnboardingPage)) - // step 1: pick Spanish (localized name the selector itself renders), continue + // step 1: pick Spanish, continue await waitFor(() => expect(screen.getAllByRole('button').length).toBeGreaterThan(0) )