- {t('goals.subtitle')}
+ {t('goals.subtitle', { language: targetLanguageName })}
{LEARNING_GOALS.map((goal) => {
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..1a25a1370
--- /dev/null
+++ b/frontend/tests/app/onboarding-goals-subtitle.test.tsx
@@ -0,0 +1,181 @@
+import React from 'react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { NextIntlClientProvider } from 'next-intl'
+import { TARGET_LANGUAGE_CATALOG } from '@/lib/target-languages'
+import { useAuthStore } from '@/store/auth'
+import { useConfigStore } from '@/store/config'
+import { useLanguageStore } from '@/store/language'
+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 { searchParams, apiFetch } = vi.hoisted(() => ({
+ searchParams: new URLSearchParams(),
+ apiFetch: vi.fn(),
+}))
+
+vi.mock('next/navigation', () => ({
+ useRouter: () => ({ push: vi.fn() }),
+ useSearchParams: () => searchParams,
+}))
+
+vi.mock('@/lib/api', () => ({ apiFetch }))
+
+import OnboardingPage from '@/app/(auth)/onboarding/page'
+
+const catalogs = { de, en, es, fr, it: itMessages, nl, pl, pt, ro, ru }
+type Locale = keyof typeof catalogs
+
+const spanishSubtitles: Record
= {
+ de: 'Sprache: Spanisch. Wofür möchtest du sie nutzen? Wähle alle zutreffenden Ziele aus.',
+ en: 'Language: Spanish. What do you want to use it for? Select all that apply.',
+ es: 'Idioma: español. ¿Para qué quieres utilizarlo? Selecciona todos los objetivos que correspondan.',
+ fr: "Langue : espagnol. Dans quel but souhaitez-vous l'utiliser ? Sélectionnez tous les objectifs qui vous correspondent.",
+ it: 'Lingua: spagnolo. Per cosa vuoi usarla? Seleziona tutti gli obiettivi che fanno al caso tuo.',
+ nl: 'Taal: Spaans. Waarvoor wil je deze taal gebruiken? Selecteer alle doelen die van toepassing zijn.',
+ pl: 'Język: hiszpański. Do czego chcesz go używać? Zaznacz wszystkie cele, które Ci odpowiadają.',
+ pt: 'Idioma: espanhol. Para que você quer usá-lo? Selecione todos os objetivos que se aplicam.',
+ ro: 'Limbă: spaniolă. Pentru ce vrei să o folosești? Selectează toate obiectivele care ți se potrivesc.',
+ ru: 'Язык: испанский. Для чего ты хочешь его использовать? Выбери все подходящие цели.',
+}
+
+const onIntlError = vi.fn()
+const englishSubtitle =
+ 'Language: English. What do you want to use it for? Select all that apply.'
+
+function languageResponse(
+ codes = TARGET_LANGUAGE_CATALOG.map((language) => language.code)
+) {
+ return new Response(
+ JSON.stringify({ languages: [], all_supported_languages: codes })
+ )
+}
+
+function renderOnboarding(locale: Locale = 'en') {
+ return render(
+
+
+
+ )
+}
+
+beforeEach(() => {
+ for (const key of Array.from(searchParams.keys())) searchParams.delete(key)
+ onIntlError.mockReset()
+ apiFetch.mockReset()
+ apiFetch.mockImplementation(async () => languageResponse())
+ useAuthStore.setState({ ...useAuthStore.getInitialState() }, true)
+ useConfigStore.setState(
+ { ...useConfigStore.getInitialState(), loaded: true },
+ true
+ )
+ useLanguageStore.setState({ ...useLanguageStore.getInitialState() }, true)
+})
+
+afterEach(() => {
+ cleanup()
+ expect(onIntlError).not.toHaveBeenCalled()
+})
+
+describe('onboarding goals subtitle', () => {
+ it.each(Object.keys(catalogs) as Locale[])(
+ 'names the selected language with natural wording in the %s UI',
+ async (locale) => {
+ const messages = catalogs[locale]
+ renderOnboarding(locale)
+
+ fireEvent.click(
+ await screen.findByRole('button', {
+ name: new RegExp(messages.targetLanguages['es-ES']),
+ })
+ )
+ fireEvent.click(
+ screen.getByRole('button', { name: messages.common.next })
+ )
+
+ expect(screen.getByText(spanishSubtitles[locale])).toBeInTheDocument()
+ }
+ )
+
+ it.each([
+ [null, 'English'],
+ ['en-GB', 'English'],
+ ['en-US', 'English'],
+ ['de-DE', 'German'],
+ ['fr-FR', 'French'],
+ ['it-IT', 'Italian'],
+ ['pt-PT', 'Portuguese'],
+ ['ja-JP', 'Japanese'],
+ ['ko-KR', 'Korean'],
+ ['zh-CN', 'Chinese'],
+ ])(
+ 'uses the language-level name for query language=%s',
+ async (code, name) => {
+ if (code) searchParams.set('language', code)
+ renderOnboarding()
+ await screen.findByRole('button', { name: /Spanish/ })
+ fireEvent.click(screen.getByRole('button', { name: 'Next' }))
+
+ expect(
+ screen.getByText(
+ `Language: ${name}. What do you want to use it for? Select all that apply.`
+ )
+ ).toBeInTheDocument()
+ }
+ )
+
+ it.each(['', 'unknown-language'])(
+ 'safely displays the default for language=%s while languages load',
+ async (code) => {
+ searchParams.set('language', code)
+ let resolveLanguages!: (response: Response) => void
+ apiFetch.mockReturnValueOnce(
+ new Promise((resolve) => {
+ resolveLanguages = resolve
+ })
+ )
+ renderOnboarding()
+ fireEvent.click(screen.getByRole('button', { name: 'Next' }))
+
+ expect(screen.getByText(englishSubtitle)).toBeInTheDocument()
+
+ await act(async () => resolveLanguages(languageResponse(['fr-FR'])))
+
+ expect(
+ screen.getByText(
+ 'Language: French. What do you want to use it for? Select all that apply.'
+ )
+ ).toBeInTheDocument()
+ }
+ )
+
+ it.each(['HTTP', 'network'])(
+ 'safely displays the default for an invalid query after a %s failure',
+ async (failure) => {
+ searchParams.set('language', 'unknown-language')
+ if (failure === 'HTTP') {
+ apiFetch.mockResolvedValueOnce(new Response('', { status: 503 }))
+ } else {
+ apiFetch.mockRejectedValueOnce(new Error('offline'))
+ }
+ renderOnboarding()
+ await screen.findByText(en.onboarding.saveFailed)
+ fireEvent.click(screen.getByRole('button', { name: 'Next' }))
+
+ expect(screen.getByText(englishSubtitle)).toBeInTheDocument()
+ }
+ )
+})
diff --git a/messages/de.json b/messages/de.json
index b9e1c0de3..6829ed328 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": "Sprache: {language}. Wofür möchtest du sie nutzen? Wähle alle zutreffenden Ziele aus.",
"skip": "Überspringen",
"travel": "Reisen & Tourismus",
"work": "Beruf & Karriere",
diff --git a/messages/en.json b/messages/en.json
index cbf8fe81a..4d17ca93d 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": "Language: {language}. What do you want to use it for? Select all that apply.",
"skip": "Skip",
"travel": "Travel & Tourism",
"work": "Work & Professional",
diff --git a/messages/es.json b/messages/es.json
index a7cda5d2e..0dadce61d 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": "Idioma: {language}. ¿Para qué quieres utilizarlo? Selecciona todos los objetivos que correspondan.",
"skip": "Omitir",
"travel": "Viajes y turismo",
"work": "Trabajo y carrera",
diff --git a/messages/fr.json b/messages/fr.json
index aac8dcc4b..2c5c0dae3 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": "Langue : {language}. Dans quel but souhaitez-vous l'utiliser ? Sélectionnez tous les objectifs qui vous correspondent.",
"skip": "Passer",
"travel": "Voyages et tourisme",
"work": "Travail et carrière",
diff --git a/messages/it.json b/messages/it.json
index 382326ea8..0754c2b94 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": "Lingua: {language}. Per cosa vuoi usarla? Seleziona tutti gli obiettivi che fanno al caso tuo.",
"skip": "Salta",
"travel": "Viaggi e turismo",
"work": "Lavoro e carriera",
diff --git a/messages/nl.json b/messages/nl.json
index 3a4fbb3f7..afc32d746 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": "Taal: {language}. Waarvoor wil je deze taal gebruiken? Selecteer alle doelen die van toepassing zijn.",
"skip": "Overslaan",
"travel": "Reizen en toerisme",
"work": "Werk en carrière",
diff --git a/messages/pl.json b/messages/pl.json
index bcf7ca834..f04fb339f 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": "Język: {language}. Do czego chcesz go używać? Zaznacz wszystkie cele, które Ci odpowiadają.",
"skip": "Pominąć",
"travel": "Podróże i turystyka",
"work": "Praca i kariera",
diff --git a/messages/pt.json b/messages/pt.json
index f82d1be2e..6cb871681 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": "Idioma: {language}. Para que você quer usá-lo? Selecione todos os objetivos que se aplicam.",
"skip": "Pular",
"travel": "Viagens e turismo",
"work": "Trabalho e carreira",
diff --git a/messages/ro.json b/messages/ro.json
index de8b09251..bd6435d00 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": "Limbă: {language}. Pentru ce vrei să o folosești? Selectează toate obiectivele care ți se potrivesc.",
"skip": "Sari",
"travel": "Călătorii și turism",
"work": "Muncă și carieră",
diff --git a/messages/ru.json b/messages/ru.json
index d590c07ed..77ff8941e 100644
--- a/messages/ru.json
+++ b/messages/ru.json
@@ -1295,7 +1295,7 @@
"saveFailed": "Не удалось сохранить настройки",
"goals": {
"title": "Твои цели обучения",
- "subtitle": "Для чего ты хочешь использовать английский? Выбери всё, что подходит.",
+ "subtitle": "Язык: {language}. Для чего ты хочешь его использовать? Выбери все подходящие цели.",
"skip": "Пропустить",
"travel": "Путешествия и туризм",
"work": "Работа и карьера",
diff --git a/specs/target-language.instructions.md b/specs/target-language.instructions.md
index 33393e745..f17deb08e 100644
--- a/specs/target-language.instructions.md
+++ b/specs/target-language.instructions.md
@@ -74,6 +74,13 @@ The current frontend registration form does not send target language. Onboarding
6. Creates or activates the initial `UserLanguage`.
7. Navigates to Dashboard; assessment remains a separate next action.
+The learning-goals subtitle identifies the selected language using the localized `targetLanguages`
+ISO 639-1 entry rather than the regional selector label. The name appears as a standalone language
+label, so translations do not require language-specific articles or inflections. Unrecognized
+selection codes use the language of `DEFAULT_TARGET_LANGUAGE` (`en-GB`) for display, including while
+the available-language request is pending or has failed. This display fallback does not change the
+selected code or replace backend validation.
+
## Compatibility field
`users.target_language` remains part of registration and profile responses for defaults and older
From 40185922dc6c47d6ff56d8883bf4d7f6cc8e6344 Mon Sep 17 00:00:00 2001
From: Arturo Carretero Calvo <10163049+ArtCC@users.noreply.github.com>
Date: Thu, 24 Sep 2026 06:50:13 +0200
Subject: [PATCH 5/6] Bump version to 1.9.20
- Update canonical version references in AGENTS.md, README.md, and specs/version.md
- Mark CHANGELOG.md with the 1.9.20 release date
- Refresh the app shell and Whats New version strings to v1.9.20
- Align localization files with the new Whats New version and preserve JSON formatting
- Adjust JSX formatting in the app layout className expressions and trial day calculation indentation
---
AGENTS.md | 2 +-
CHANGELOG.md | 2 +-
README.md | 2 +-
frontend/src/app/(app)/layout.tsx | 46 ++++++++-----------
.../src/components/whats-new/WhatsNew.tsx | 2 +-
messages/de.json | 4 +-
messages/en.json | 4 +-
messages/es.json | 4 +-
messages/fr.json | 4 +-
messages/it.json | 4 +-
messages/nl.json | 4 +-
messages/pl.json | 4 +-
messages/pt.json | 4 +-
messages/ro.json | 4 +-
messages/ru.json | 4 +-
specs/version.md | 2 +-
16 files changed, 44 insertions(+), 52 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index 9708e912a..2f92d4cea 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,6 +1,6 @@
# AGENTS.md — FreeLingo
-**Current version: 1.9.15**
+**Current version: 1.9.20**
## Project
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5b239e895..1a9756650 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
-## [Unreleased]
+## [1.9.20] - 2026-09-24
### Fixed
diff --git a/README.md b/README.md
index 82daebd96..5617e636b 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@



-
+
diff --git a/frontend/src/app/(app)/layout.tsx b/frontend/src/app/(app)/layout.tsx
index 15cf0eee7..de650fe7c 100644
--- a/frontend/src/app/(app)/layout.tsx
+++ b/frontend/src/app/(app)/layout.tsx
@@ -135,7 +135,7 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {
1,
Math.ceil(
(new Date(user.subscription_ends_at).getTime() - Date.now()) /
- (1000 * 60 * 60 * 24)
+ (1000 * 60 * 60 * 24)
)
)
setTrialDaysLeft(days)
@@ -234,11 +234,10 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {
●
{tNav('admin')}
@@ -379,7 +375,7 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {