feat(progression): page /progression + section Dashboard Premium — patterns, exercices long terme, indice de préparation (Sprint 3.6c)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hermann_Kitio 2026-04-22 23:12:23 +03:00
parent a752029c19
commit a60c298605
18 changed files with 1005 additions and 7 deletions

View file

@ -0,0 +1,118 @@
/**
* MonProfilPreparation Sprint 3.6c.
*
* Section compacte du Dashboard Premium qui résume l'analyse des patterns :
* - Premium + ready indice de préparation + « N erreurs récurrentes » + CTA
* - Premium + not-ready message compact « Encore X simulations »
* - Free + Standard ne rend rien (composant court-circuite)
*
* Le hook `usePatterns` court-circuite déjà la requête côté client si
* !hasAccess(plan, 'pattern_analysis'), donc aucun appel backend parasite
* pour Free/Standard. La garde locale ici empêche aussi un flash de contenu
* si le composant est monté par erreur.
*
* Règle D : gating via hasAccess, jamais `plan === 'premium'`.
* Règle L : tokens Direction H exclusivement.
*/
import { Link } from 'react-router-dom'
import { ArrowRight } from 'lucide-react'
import { Card } from '@/shared/ui/Card'
import { Button } from '@/shared/ui/Button'
import { hasAccess, type Plan } from '@/entities/user/lib'
import { usePatterns } from '@/features/progression/hooks/usePatterns'
interface Props {
plan: Plan
}
function gaugeColor(score: number): string {
if (score < 40) return 'bg-danger'
if (score <= 70) return 'bg-warning'
return 'bg-success'
}
export function MonProfilPreparation({ plan }: Props) {
// Garde explicite (cohérent avec la logique du hook qui a déjà `enabled`).
if (!hasAccess(plan, 'pattern_analysis')) return null
const { data, isLoading, isError } = usePatterns(plan)
if (isLoading || isError || !data) {
return (
<Card variant="default" className="p-4">
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5">
Mon profil de préparation
</p>
<p className="mt-2 text-sm text-ink-4">
{isError ? 'Profil temporairement indisponible.' : 'Chargement…'}
</p>
</Card>
)
}
if (!data.ready) {
const remaining = Math.max(0, data.minimum - data.current)
return (
<Card variant="default" className="space-y-2 p-4">
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5">
Mon profil de préparation
</p>
<p className="text-sm text-ink-2">
Encore{' '}
<span className="font-semibold tabular-nums">{remaining}</span>{' '}
{remaining > 1 ? 'simulations' : 'simulation'} pour débloquer votre profil.
</p>
<p className="text-xs text-ink-4 tabular-nums">
{data.current}/{data.minimum} simulations corrigées
</p>
</Card>
)
}
const patternsCount = data.patterns.length
const pct = Math.max(0, Math.min(100, data.preparation_index.score))
const color = gaugeColor(pct)
return (
<Card variant="raised" className="space-y-3 p-4">
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5">
Indice de préparation
</p>
<p className="tabular-nums text-ink-1">
<span className="text-3xl font-bold">{data.preparation_index.score}</span>
<span className="text-lg font-medium text-ink-4">/100</span>
</p>
</div>
<p className="max-w-[180px] text-right text-xs text-ink-3">
{data.preparation_index.message}
</p>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-canvas-2">
<div
className={`h-full transition-all duration-300 ${color}`}
style={{ width: `${pct}%` }}
/>
</div>
<p className="text-sm text-ink-2">
{patternsCount === 0
? 'Aucune erreur récurrente identifiée — continuez !'
: `${patternsCount} ${patternsCount > 1 ? 'erreurs récurrentes identifiées' : 'erreur récurrente identifiée'}.`}
</p>
<Button variant="secondary" size="sm" className="w-full">
<Link
to="/progression"
className="-m-1 flex items-center justify-center gap-1.5 p-1"
>
Voir mon profil de préparation
<ArrowRight className="size-3.5" aria-hidden="true" />
</Link>
</Button>
</Card>
)
}

View file

@ -0,0 +1,141 @@
/**
* Tests MonProfilPreparation (Sprint 3.6c).
*
* Couvre le gating plan : absent Free/Standard, visible Premium (ready + not-ready).
* Le hook `usePatterns` est mocké pour isoler la présentation.
*/
import { describe, it, expect, vi, afterEach } from 'vitest'
import { render, screen, cleanup } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
vi.mock('@/features/progression/hooks/usePatterns', () => ({
usePatterns: vi.fn(),
}))
import { usePatterns } from '@/features/progression/hooks/usePatterns'
import { MonProfilPreparation } from '../MonProfilPreparation'
afterEach(cleanup)
function renderWithRouter(ui: React.ReactNode) {
return render(<MemoryRouter>{ui}</MemoryRouter>)
}
describe('MonProfilPreparation — gating plan', () => {
it('plan free → ne rend rien', () => {
const { container } = renderWithRouter(<MonProfilPreparation plan="free" />)
expect(container).toBeEmptyDOMElement()
})
it('plan standard → ne rend rien', () => {
const { container } = renderWithRouter(<MonProfilPreparation plan="standard" />)
expect(container).toBeEmptyDOMElement()
})
})
describe('MonProfilPreparation — plan premium', () => {
it('ready: true → affiche score, message, nb patterns, CTA /progression', () => {
vi.mocked(usePatterns).mockReturnValue({
data: {
ready: true,
patterns: [
{
code: 'accord_sujet_verbe',
critere: 'competence_grammaticale',
frequency: 4,
description: null,
},
{
code: 'connecteurs_repetes',
critere: 'coherence_cohesion',
frequency: 3,
description: null,
},
{
code: 'repetition_lexicale',
critere: 'competence_lexicale',
frequency: 3,
description: null,
},
],
exercises: [],
preparation_index: { score: 72, message: 'Vous êtes en bonne voie pour NCLC 9+' },
analyzed_productions: 5,
last_analysis: '2026-04-22T12:00:00Z',
},
isLoading: false,
isError: false,
} as unknown as ReturnType<typeof usePatterns>)
renderWithRouter(<MonProfilPreparation plan="premium" />)
expect(screen.getByText('72')).toBeInTheDocument()
expect(screen.getByText(/NCLC 9/)).toBeInTheDocument()
expect(screen.getByText(/3 erreurs récurrentes identifiées/i)).toBeInTheDocument()
expect(screen.getByRole('link', { name: /voir mon profil de préparation/i })).toHaveAttribute(
'href',
'/progression',
)
})
it('ready: false → message compact "Encore X simulations"', () => {
vi.mocked(usePatterns).mockReturnValue({
data: { ready: false, minimum: 5, current: 2 },
isLoading: false,
isError: false,
} as unknown as ReturnType<typeof usePatterns>)
renderWithRouter(<MonProfilPreparation plan="premium" />)
expect(screen.getByText(/encore/i)).toBeInTheDocument()
// Le nombre restant (3) est dans un span séparé du mot "simulations"
expect(screen.getByText('3')).toBeInTheDocument()
expect(screen.getByText(/pour débloquer votre profil/i)).toBeInTheDocument()
expect(screen.getByText(/2\/5 simulations corrigées/i)).toBeInTheDocument()
})
it('isLoading → placeholder "Chargement"', () => {
vi.mocked(usePatterns).mockReturnValue({
data: undefined,
isLoading: true,
isError: false,
} as unknown as ReturnType<typeof usePatterns>)
renderWithRouter(<MonProfilPreparation plan="premium" />)
expect(screen.getByText(/chargement/i)).toBeInTheDocument()
})
it('isError → message "temporairement indisponible"', () => {
vi.mocked(usePatterns).mockReturnValue({
data: undefined,
isLoading: false,
isError: true,
} as unknown as ReturnType<typeof usePatterns>)
renderWithRouter(<MonProfilPreparation plan="premium" />)
expect(screen.getByText(/temporairement indisponible/i)).toBeInTheDocument()
})
it('ready: true avec 0 pattern → message "Aucune erreur récurrente"', () => {
vi.mocked(usePatterns).mockReturnValue({
data: {
ready: true,
patterns: [],
exercises: [],
preparation_index: { score: 85, message: 'Vous êtes en bonne voie pour NCLC 9+' },
analyzed_productions: 5,
last_analysis: '2026-04-22T12:00:00Z',
},
isLoading: false,
isError: false,
} as unknown as ReturnType<typeof usePatterns>)
renderWithRouter(<MonProfilPreparation plan="premium" />)
expect(screen.getByText(/aucune erreur récurrente/i)).toBeInTheDocument()
expect(screen.getByText('85')).toBeInTheDocument()
})
})