Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, 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.
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/app/(auth)/onboarding/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ function getSelectedPlan(plan: string | null): BillingInterval | null {
export default function OnboardingPage() {
const t = useTranslations('onboarding')
const tCommon = useTranslations('common')
const tLang = useTranslations('targetLanguages')
const router = useRouter()
const searchParams = useSearchParams()
const setUser = useAuthStore((s) => s.setUser)
Expand Down Expand Up @@ -266,7 +267,9 @@ export default function OnboardingPage() {
{step === 2 && (
<div className="space-y-5">
<p className="text-fl-fg font-mono text-sm">
{t('goals.subtitle')}
{t('goals.subtitle', {
language: tLang(targetLanguage),
})}
</p>
<div className="grid grid-cols-2 gap-2">
{LEARNING_GOALS.map((goal) => {
Expand Down
125 changes: 125 additions & 0 deletions frontend/tests/app/onboarding-goals-subtitle.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
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 } = vi.hoisted(() => ({
mockApiFetch: vi.fn(),
mockPush: vi.fn(),
}))

// 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<typeof import('next-intl')>()
return {
...actual,
useTranslations: (namespace: string) => {
const messages = enMessages as Record<string, unknown>
const scope = messages[namespace] as Record<string, unknown>
return (key: string, values?: Record<string, string>) => {
let raw: unknown = scope?.[key]
if (raw === undefined && key.includes('.')) {
raw = key
.split('.')
.reduce<
Record<string, unknown> | undefined
>((node, part) => (node && typeof node === 'object' ? (node[part] as Record<string, unknown>) : undefined), scope)
}
let text = typeof raw === 'string' ? raw : key
for (const [name, value] of Object.entries(values ?? {})) {
text = text.replaceAll(`{${name}}`, value)
}
return text
}
},
// real useLocale needs a provider; pin the UI locale
useLocale: () => 'en',
}
})

vi.mock('next/navigation', () => ({
useRouter: () => ({ push: mockPush }),
useSearchParams: () => new URLSearchParams(),
usePathname: () => '/onboarding',
}))

vi.mock('@/lib/api', () => ({
apiFetch: mockApiFetch,
}))

vi.mock('@/store/auth', () => ({
useAuthStore: (selector: (state: Record<string, unknown>) => 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<string, unknown>) => 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<string, unknown>) => unknown
) => selector(mockLanguageState)
;(useLanguageStore as unknown as Record<string, unknown>).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, 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')
})
})
36 changes: 36 additions & 0 deletions frontend/tests/i18n/onboarding-messages.test.ts
Original file line number Diff line number Diff line change
@@ -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
)
}
})
})
2 changes: 1 addition & 1 deletion messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion messages/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion messages/nl.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion messages/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion messages/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion messages/ro.json
Original file line number Diff line number Diff line change
Expand Up @@ -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ă",
Expand Down
2 changes: 1 addition & 1 deletion messages/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -1295,7 +1295,7 @@
"saveFailed": "Не удалось сохранить настройки",
"goals": {
"title": "Твои цели обучения",
"subtitle": "Для чего ты хочешь использовать английский? Выбери всё, что подходит.",
"subtitle": "Для чего ты хочешь использовать {language}? Выбери всё, что подходит.",
"skip": "Пропустить",
"travel": "Путешествия и туризм",
"work": "Работа и карьера",
Expand Down
Loading