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

@ -51,6 +51,42 @@ Chaque entrée suit ce format :
- B8 : comportement actuel diffère du spec `PARCOURS_UTILISATEURS.md §2 "Quota atteint"` — affichage d'une bannière inline au lieu du modal de blocage attendu. À corriger dans un sprint dédié (non inclus dans ce clean, qui n'introduit aucune nouvelle fonctionnalité). - B8 : comportement actuel diffère du spec `PARCOURS_UTILISATEURS.md §2 "Quota atteint"` — affichage d'une bannière inline au lieu du modal de blocage attendu. À corriger dans un sprint dédié (non inclus dans ce clean, qui n'introduit aucune nouvelle fonctionnalité).
## [Unreleased] — 2026-04-22 — Sprint 3.6c — Analyse patterns (Backend + Frontend)
### Added (backend)
- `GET /users/patterns` — analyse des patterns récurrents pour utilisateur Premium.
- Auth : `authMiddleware` + `planMiddleware('pattern_analysis')` (403 `PLAN_INSUFFICIENT` si Free/Standard).
- < 5 productions corrigées `200 { ready: false, minimum: 5, current: N }`.
- ≥ 5 → `200 { ready: true, patterns, exercises, preparation_index, analyzed_productions, last_analysis }`.
- `patternsController.aggregatePatterns` (pure) : agrège les `erreurs_codes` sur N productions, seuil 3/5, dédoublonnage intra-prod (un même code dans un rapport ne compte qu'une fois), codes `autre` distingués par description, tri par fréquence DESC.
- `patternsController.computePreparationIndex` (pure) : 60 % score moyen normalisé + 20 % régularité (médiane des intervalles entre prod) + 20 % tendance (pente linéaire sur les 5 scores). Clamp `[0, 100]`, messages figés selon les seuils `<40` / `40-70` / `>70`.
- `patternsController.list` — orchestre fetch productions + cache `pattern_analyses` + recompute + DeepSeek + INSERT. Stratégie d'invalidation : `MAX(productions.created_at) > lastAnalysis.created_at` → recompute, sinon cache hit.
- `generatePatternExercices` dans `src/lib/deepseek.ts` — prompt système validé par Hermann avec format `{ consigne, exemple, correction, astuce }`, température 0.4, `AbortSignal.timeout(20_000)`, validation runtime des critères via `isValidCritere`.
- Table `pattern_analyses` — migration `005_sprint_3_6c_pattern_analyses.sql` : UUID PK + FK cascade user_id + `productions_ids UUID[]` + patterns/exercises JSONB + preparation_index (CHECK `[0, 100]`) + preparation_message + analyzed_count + RLS SELECT par user_id + index `(user_id, created_at DESC)`.
- 19 nouveaux tests (`patternsController.test.ts`) : 7 sur `aggregatePatterns`, 4 sur `computePreparationIndex`, 8 sur route (401, 403 free/standard, <5 prod, cache hit, cache miss + insert, no patterns, DeepSeek fail gracieux). **205 tests backend verts** (+19 vs baseline 186).
### Added (frontend)
- Page `/progression` — route sous `AppLayout` + `ProtectedRoute`, remplace le placeholder `ComingSoon`.
- `ProgressionPage` — orchestre `usePlan` + `usePatterns`, gate plan via `hasAccess('pattern_analysis')`.
- `ProgressionPremium` — orchestrateur : si not-ready → `NotReadyState` ; sinon Hero indice + patterns + exercices long terme + footer « Analyse basée sur vos N dernières productions — il y a X ».
- `PreparationIndexHero` — score /100 + jauge horizontale colorée (rouge <40 / ambre 40-70 / vert >70) + message.
- `PatternsList` — liste des patterns avec libellé via nouveau `CRITERE_LABELS` + badge fréquence (3/5, 4/5, 5/5).
- **`PatternExerciceCard`** — *nouveau composant lesson-style*, non interactif (contrairement à `ExerciceInteractive` du rapport individuel) : critère + diagnostic + consigne + bloc incorrect (barré rouge) côte à côte avec bloc correct (vert) + **encart astuce proéminent** (icône ampoule + fond warning).
- `NotReadyState` — barre de progression N/5 + CTA `Démarrer une simulation`.
- `BlurredProgression` — aperçu flouté pour Free/Standard + bouton upgrade Premium.
- Section Dashboard Premium `MonProfilPreparation` — MetricCard indice (score + jauge compacte + message) + nombre d'erreurs récurrentes + CTA « Voir mon profil de préparation » vers `/progression`. Garde explicite `hasAccess('pattern_analysis')` → composant retourne `null` pour Free/Standard (pas rendu dans le DOM).
- `usePatterns(plan)` — hook TanStack Query partagé entre `/progression` et dashboard ; clé `['users', 'patterns']`, `staleTime: 60s`, `enabled` conditionné par `hasAccess` pour éviter un 403 parasite.
- `entities/patterns/types.ts` + `entities/patterns/api.ts` — types miroirs du backend (`Pattern`, `PatternExercice`, `PreparationIndex`, `PatternsReady`, `PatternsNotReady`) + `getPatterns()` avec timeout 25 s.
- `CRITERE_LABELS` exporté depuis `entities/report/lib.ts` — miroir du backend pour affichage du libellé humain à partir du code taxonomie.
- 13 nouveaux tests : 6 sur `ProgressionPremium` (not-ready, ready avec indice/patterns/exercices, footer, 0 pattern) + 7 sur `MonProfilPreparation` (gating Free/Standard, Premium ready/not-ready, loading, error, 0 pattern). **115 tests frontend verts** (+13 vs baseline 102).
### Notes
- **Formule indice** arbitraire (60/20/20) — à affiner après observation prod si besoin.
- **Dégradation gracieuse DeepSeek** : si `generatePatternExercices` throw, le backend persiste quand même l'analyse avec `exercises: []` et logue l'erreur. Le frontend affiche alors la liste des patterns sans section exercices (pas de message d'erreur explicite côté UI — l'utilisateur ne sait pas qu'il manque quelque chose).
- **`ExerciceInteractive` NON réutilisé** pour les exercices long terme : les shapes et UX sont différents (lesson vs tentative). Deux composants distincts cohabitent.
- **Migration SQL à exécuter manuellement** : `cd expria-backend && supabase db push` avant les tests end-to-end Premium.
## [Unreleased] — 2026-04-22 — Sprint 3.7 — Historique (Backend + Frontend) ## [Unreleased] — 2026-04-22 — Sprint 3.7 — Historique (Backend + Frontend)
### Added (backend) ### Added (backend)

View file

@ -64,12 +64,17 @@
- Helper `formatRelativeDate` (Intl.RelativeTimeFormat, zéro dépendance). - Helper `formatRelativeDate` (Intl.RelativeTimeFormat, zéro dépendance).
- 102 tests frontend verts (+18 vs baseline 84). - 102 tests frontend verts (+18 vs baseline 84).
## Sprint 3.6c — Analyse patterns (Premium) ## Sprint 3.6c — Analyse patterns (Premium) ✅
- Backend : GET /users/patterns — agrégation SQL erreurs_codes sur 5 dernières productions - Backend : `GET /users/patterns` — agrégation des `erreurs_codes` sur les 5 dernières productions corrigées, seuil 3/5, tri DESC, cache `pattern_analyses` avec invalidation si nouvelle production plus récente que la dernière analyse.
- Backend : exercices long terme générés par DeepSeek sur patterns confirmés (≥ 3/5) - Backend : exercices long terme générés par DeepSeek sur patterns confirmés — format `{ consigne, exemple, correction, astuce }` avec prompt dédié (température 0.4, timeout 20 s). Dégradation gracieuse si DeepSeek échoue.
- Backend : indice de préparation 0→100 - Backend : indice de préparation 0→100 — formule 60 % score moyen + 20 % régularité + 20 % tendance, messages figés (`<40`, `40-70`, `>70`).
- Frontend : Dashboard Premium — section "Mon profil de préparation" - Backend : migration SQL `005_sprint_3_6c_pattern_analyses.sql` (RLS SELECT par user_id, index composite, CHECK constraints).
- Frontend : erreurs récurrentes + exercices long terme + indice - Backend : 205 tests verts (+19 vs baseline 186).
- Frontend : page `/progression` — orchestration hero (indice + jauge), liste patterns, cartes exercices long terme, footer « il y a X » ; gate plan via `hasAccess('pattern_analysis')` (Free/Standard → aperçu flouté + upgrade).
- Frontend : `PatternExerciceCard` — composant lesson-style dédié (non interactif, UX distincte de `ExerciceInteractive`) avec encart astuce proéminent.
- Frontend : Dashboard Premium — section compacte `MonProfilPreparation` (MetricCard indice + nb patterns + CTA vers `/progression`). Absente pour Free/Standard.
- Frontend : hook `usePatterns` (staleTime 60 s, cache partagé entre page et dashboard, `enabled` conditionné par feature).
- Frontend : 115 tests verts (+13 vs baseline 102).
## Sprint 4 — Simulations EO (audio) ## Sprint 4 — Simulations EO (audio)
16. MediaRecorder + upload audio EO T1/T3 16. MediaRecorder + upload audio EO T1/T3

View file

@ -9,6 +9,7 @@ import { SimulationPage } from '@/features/simulations/pages/SimulationPage'
import { SujetsPage } from '@/features/simulations/pages/SujetsPage' import { SujetsPage } from '@/features/simulations/pages/SujetsPage'
import { RapportPage } from '@/features/simulations/pages/RapportPage' import { RapportPage } from '@/features/simulations/pages/RapportPage'
import { HistoriquePage } from '@/features/historique/pages/HistoriquePage' import { HistoriquePage } from '@/features/historique/pages/HistoriquePage'
import { ProgressionPage } from '@/features/progression/pages/ProgressionPage'
import { SimulationFlowProvider } from '@/features/simulations/state/SimulationFlowProvider' import { SimulationFlowProvider } from '@/features/simulations/state/SimulationFlowProvider'
import { AppLayout } from './AppLayout' import { AppLayout } from './AppLayout'
@ -70,7 +71,7 @@ export function AppRouter() {
{/* Autres sections — Sprint 4+ */} {/* Autres sections — Sprint 4+ */}
<Route path="/examen" element={<ComingSoon />} /> <Route path="/examen" element={<ComingSoon />} />
<Route path="/progression" element={<ComingSoon />} /> <Route path="/progression" element={<ProgressionPage />} />
<Route path="/methodologie" element={<ComingSoon />} /> <Route path="/methodologie" element={<ComingSoon />} />
<Route path="/historique" element={<HistoriquePage />} /> <Route path="/historique" element={<HistoriquePage />} />
<Route path="/plan" element={<ComingSoon />} /> <Route path="/plan" element={<ComingSoon />} />

View file

@ -0,0 +1,23 @@
/**
* Appels API du domaine `patterns`.
*
* Endpoint unique : `GET /users/patterns`.
* - Plan non-Premium 403 PLAN_INSUFFICIENT (géré côté hook via `enabled`).
* - < 5 productions corrigées 200 { ready: false, minimum, current }.
* - 5 productions 200 { ready: true, patterns, exercises, preparation_index, ... }.
*
* Timeout par défaut : 10 s (largement suffisant le backend retourne sur cache
* sauf recompute + DeepSeek qui peut prendre jusqu'à 20 s côté serveur, mais
* reste sous le timeout HTTP côté proxy). Retry activé par défaut sur GET.
*/
import { apiFetch } from '@/shared/lib/api-client'
import type { PatternsResponse } from './types'
const PATTERNS_TIMEOUT_MS = 25_000
export function getPatterns(): Promise<PatternsResponse> {
return apiFetch<PatternsResponse>('/users/patterns', {
timeoutMs: PATTERNS_TIMEOUT_MS,
})
}

View file

@ -0,0 +1,52 @@
/**
* Types publics du domaine `patterns` Sprint 3.6c.
*
* Miroir de la réponse backend `GET /users/patterns` (cf. expria-backend
* Sprint 3.6c, commit c48ae8d). Feature Premium uniquement le gating se
* fait via `hasAccess(plan, 'pattern_analysis')` côté frontend ; la route
* backend renvoie 403 `PLAN_INSUFFICIENT` si plan non Premium (fallback
* défensif).
*/
import type { CritereCode } from '@/entities/report/types'
export interface Pattern {
code: string
critere: CritereCode
frequency: number // 3, 4 ou 5 (seuil d'agrégation : ≥ 3)
description: string | null // non-null uniquement pour code === 'autre'
}
export interface PatternExercice {
code: string
critere: CritereCode
diagnostic: string
exercice: {
consigne: string
exemple: string // phrase incorrecte générique (pas du candidat)
correction: string // version correcte
astuce: string // procédé mnémotechnique / réflexe de relecture
}
}
export interface PreparationIndex {
score: number // 0-100 entier
message: string // interprétation textuelle fixée par le backend
}
export interface PatternsReady {
ready: true
patterns: Pattern[]
exercises: PatternExercice[]
preparation_index: PreparationIndex
analyzed_productions: number
last_analysis: string // ISO timestamp
}
export interface PatternsNotReady {
ready: false
minimum: number // toujours 5 côté backend actuel
current: number // nb de productions corrigées déjà réalisées
}
export type PatternsResponse = PatternsReady | PatternsNotReady

View file

@ -87,3 +87,15 @@ export function ecartVsCible(score: number, nclcCible: number): {
} }
export type { Critere } export type { Critere }
/**
* Libellés officiels des 4 critères TCF Canada miroir de backend
* `src/lib/taxonomieErreurs.ts` CRITERE_LABELS. Utilisé par les listes de
* patterns et tout affichage nécessitant le libellé humain à partir du code.
*/
export const CRITERE_LABELS: Record<CritereCode, string> = {
adequation_tache: 'Adéquation à la tâche et au registre',
coherence_cohesion: 'Cohérence et cohésion du discours',
competence_lexicale: 'Compétence lexicale',
competence_grammaticale: 'Compétence grammaticale',
}

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()
})
})

View file

@ -15,6 +15,7 @@ import { useAuth } from '@/features/auth/hooks/useAuth'
import { usePlan } from '../hooks/usePlan' import { usePlan } from '../hooks/usePlan'
import { PaywallBanner } from '../components/PaywallBanner' import { PaywallBanner } from '../components/PaywallBanner'
import { PLAN_QUERY_KEY } from '../hooks/usePlan' import { PLAN_QUERY_KEY } from '../hooks/usePlan'
import { MonProfilPreparation } from '../components/MonProfilPreparation'
const PLAN_LABELS: Record<Plan, string> = { const PLAN_LABELS: Record<Plan, string> = {
free: 'Plan Découverte', free: 'Plan Découverte',
@ -120,6 +121,9 @@ export function DashboardPage() {
<h2 className="text-base font-semibold text-ink-1">Dernières simulations</h2> <h2 className="text-base font-semibold text-ink-1">Dernières simulations</h2>
<p className="mt-2 text-sm text-ink-4">Aucune simulation pour l'instant.</p> <p className="mt-2 text-sm text-ink-4">Aucune simulation pour l'instant.</p>
</section> </section>
{/* Mon profil de préparation — Premium uniquement (gate via hasAccess) */}
<MonProfilPreparation plan={data.plan} />
</div> </div>
)} )}
</main> </main>

View file

@ -0,0 +1,45 @@
/**
* BlurredProgression Sprint 3.6c.
*
* Aperçu flouté de la page /progression pour Free/Standard + CTA upgrade
* vers Premium. Ce composant n'est JAMAIS rendu pour Premium (cf.
* ProgressionPage) le gating est fait en amont via hasAccess.
*
* Règle L : tokens Direction H exclusivement.
*/
import { Lock } from 'lucide-react'
import { Button } from '@/shared/ui/Button'
interface Props {
onUpgrade: () => void
}
const PLACEHOLDER_HEIGHTS = ['h-24', 'h-16', 'h-16', 'h-20'] as const
export function BlurredProgression({ onUpgrade }: Props) {
return (
<div className="relative min-h-[320px] overflow-hidden rounded-lg border border-line bg-canvas-2">
<div className="space-y-3 p-4 opacity-25 blur-sm" aria-hidden="true">
{PLACEHOLDER_HEIGHTS.map((h, i) => (
<div key={i} className={`${h} rounded bg-ink-4`} />
))}
</div>
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 px-4 text-center">
<Lock className="size-6 text-ink-4" aria-hidden="true" />
<div className="space-y-1">
<p className="text-sm font-semibold text-ink-2">
Profil de préparation Exclusivité Premium
</p>
<p className="max-w-sm text-xs text-ink-4">
Analysez vos erreurs récurrentes, recevez des exercices ciblés long
terme, et suivez votre indice de préparation au TCF Canada.
</p>
</div>
<Button variant="upgrade" size="sm" onClick={onUpgrade}>
Passer en Premium
</Button>
</div>
</div>
)
}

View file

@ -0,0 +1,62 @@
/**
* NotReadyState Sprint 3.6c.
*
* Affiché quand l'utilisateur Premium a moins de 5 productions corrigées.
* Barre de progression N/5 + CTA pour démarrer une simulation.
*
* Règle L : tokens Direction H exclusivement.
*/
import { Link } from 'react-router-dom'
import { Card } from '@/shared/ui/Card'
import { Button } from '@/shared/ui/Button'
interface Props {
current: number
minimum: number
}
export function NotReadyState({ current, minimum }: Props) {
const remaining = Math.max(0, minimum - current)
const pct = Math.max(0, Math.min(100, (current / minimum) * 100))
return (
<Card variant="raised" className="space-y-4 p-6 text-center">
<div className="space-y-1">
<h2 className="text-lg font-semibold text-ink-1">Profil de préparation</h2>
<p className="text-sm leading-relaxed text-ink-3">
Vous avez réalisé{' '}
<span className="font-semibold text-ink-1 tabular-nums">
{current}/{minimum}
</span>{' '}
simulations corrigées.{' '}
{remaining > 0
? `Encore ${remaining} pour débloquer votre profil.`
: 'Votre profil va être généré à la prochaine correction.'}
</p>
</div>
<div
className="relative h-2 overflow-hidden rounded-full bg-canvas-2"
role="progressbar"
aria-valuenow={current}
aria-valuemin={0}
aria-valuemax={minimum}
aria-label={`Progression : ${current} sur ${minimum}`}
>
<div
className="h-full bg-expria transition-all duration-300"
style={{ width: `${pct}%` }}
/>
</div>
<div className="flex justify-center">
<Button variant="primary" size="sm">
<Link to="/simulation/ee" className="-m-1 p-1">
Démarrer une simulation
</Link>
</Button>
</div>
</Card>
)
}

View file

@ -0,0 +1,97 @@
/**
* PatternExerciceCard Sprint 3.6c.
*
* Carte d'exercice long terme : UX **leçon** (pas interactive, contrairement à
* `ExerciceInteractive` du rapport individuel). Le candidat a déjà répété
* cette erreur 3+ fois l'intention est de montrer directement le bon usage
* + l'astuce mnémotechnique pour réflexe de relecture.
*
* Structure :
* - En-tête : critère + badge taxonomie + diagnostic
* - Bloc consigne (fond neutre)
* - Exemple incorrect (barré rouge) Correction (fond vert)
* - Encart astuce avec icône ampoule + fond chaud
*
* Règle L : tokens Direction H exclusivement.
* Règle H : présentation pure contenu fourni par DeepSeek via backend.
*/
import { Lightbulb } from 'lucide-react'
import ReactMarkdown from 'react-markdown'
import { Card } from '@/shared/ui/Card'
import { Badge } from '@/shared/ui/Badge'
import { CRITERE_LABELS } from '@/entities/report/lib'
import type { PatternExercice } from '@/entities/patterns/types'
interface Props {
exercice: PatternExercice
}
export function PatternExerciceCard({ exercice }: Props) {
const critereLabel = CRITERE_LABELS[exercice.critere]
return (
<Card variant="default" className="space-y-4 p-4">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="neutral">{critereLabel}</Badge>
<span className="text-xs font-medium text-ink-4">
{exercice.code.replace(/_/g, ' ')}
</span>
</div>
{exercice.diagnostic && (
<p className="text-sm leading-relaxed text-ink-2">
<ReactMarkdown
disallowedElements={['script', 'iframe']}
components={{ p: ({ children }) => <span>{children}</span> }}
>
{exercice.diagnostic}
</ReactMarkdown>
</p>
)}
</div>
{exercice.exercice.consigne && (
<div className="space-y-1.5 rounded-md border border-line bg-canvas-2 p-3">
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5">
Consigne
</p>
<p className="text-sm leading-relaxed text-ink-1">
{exercice.exercice.consigne}
</p>
</div>
)}
<div className="grid gap-2 sm:grid-cols-2">
<div className="space-y-1.5 rounded-md border border-danger/30 bg-danger-bg p-3">
<p className="text-[11px] font-semibold uppercase tracking-widest text-danger">
Incorrect
</p>
<p className="text-sm leading-relaxed text-ink-1 line-through decoration-danger decoration-1">
{exercice.exercice.exemple}
</p>
</div>
<div className="space-y-1.5 rounded-md border border-success/30 bg-success-bg p-3">
<p className="text-[11px] font-semibold uppercase tracking-widest text-success">
Correct
</p>
<p className="text-sm leading-relaxed text-ink-1">
{exercice.exercice.correction}
</p>
</div>
</div>
<div className="flex gap-3 rounded-md border border-warning/30 bg-warning-bg p-3">
<Lightbulb className="mt-0.5 size-4 shrink-0 text-warning" aria-hidden="true" />
<div className="space-y-1">
<p className="text-[11px] font-semibold uppercase tracking-widest text-warning">
Astuce de relecture
</p>
<p className="text-sm leading-relaxed text-ink-1">
{exercice.exercice.astuce}
</p>
</div>
</div>
</Card>
)
}

View file

@ -0,0 +1,54 @@
/**
* PatternsList Sprint 3.6c.
*
* Liste les erreurs récurrentes détectées, groupées par critère et triées par
* fréquence DESC (déjà fait côté backend).
*
* Règle L : tokens Direction H exclusivement.
*/
import { Card } from '@/shared/ui/Card'
import { Badge } from '@/shared/ui/Badge'
import { CRITERE_LABELS } from '@/entities/report/lib'
import type { Pattern } from '@/entities/patterns/types'
interface Props {
patterns: Pattern[]
}
function humanizeCode(code: string): string {
return code.replace(/_/g, ' ')
}
export function PatternsList({ patterns }: Props) {
if (patterns.length === 0) {
return (
<Card variant="default" className="p-4">
<p className="text-sm text-ink-3">
Aucune erreur récurrente détectée sur vos 5 dernières productions.
Continuez ainsi !
</p>
</Card>
)
}
return (
<ul className="space-y-2">
{patterns.map((p) => (
<li key={`${p.critere}-${p.code}-${p.description ?? ''}`}>
<Card variant="default" className="flex items-start justify-between gap-3 p-4">
<div className="min-w-0 space-y-1">
<p className="text-sm font-semibold text-ink-1">
{p.description ?? humanizeCode(p.code)}
</p>
<p className="text-xs text-ink-4">{CRITERE_LABELS[p.critere]}</p>
</div>
<Badge variant="nclc" className="shrink-0 tabular-nums">
{p.frequency}/5
</Badge>
</Card>
</li>
))}
</ul>
)
}

View file

@ -0,0 +1,64 @@
/**
* PreparationIndexHero Sprint 3.6c.
*
* Affiche l'indice de préparation (0-100) en gros, jauge horizontale et
* message interprétatif (<40 / 40-70 / >70).
*
* Règle L : tokens Direction H exclusivement.
* Règle H : présentation pure le message vient du backend.
*/
import { Card } from '@/shared/ui/Card'
import type { PreparationIndex } from '@/entities/patterns/types'
interface Props {
index: PreparationIndex
}
function gaugeColor(score: number): string {
if (score < 40) return 'bg-danger'
if (score <= 70) return 'bg-warning'
return 'bg-success'
}
export function PreparationIndexHero({ index }: Props) {
const pct = Math.max(0, Math.min(100, index.score))
const color = gaugeColor(pct)
return (
<Card variant="raised" className="space-y-4 p-6">
<div className="flex flex-wrap items-baseline justify-between gap-4">
<div>
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5">
Indice de préparation
</p>
<p className="mt-1 tabular-nums text-ink-1">
<span className="text-5xl font-bold">{index.score}</span>
<span className="text-2xl font-medium text-ink-4">/100</span>
</p>
</div>
<p className="max-w-xs text-sm leading-relaxed text-ink-2">{index.message}</p>
</div>
<div
className="relative h-2 overflow-hidden rounded-full bg-canvas-2"
role="progressbar"
aria-valuenow={pct}
aria-valuemin={0}
aria-valuemax={100}
aria-label={`Indice de préparation : ${pct} sur 100`}
>
<div
className={`h-full transition-all duration-300 ${color}`}
style={{ width: `${pct}%` }}
/>
</div>
<div className="flex justify-between text-xs text-ink-4 tabular-nums">
<span>0</span>
<span>40</span>
<span>70</span>
<span>100</span>
</div>
</Card>
)
}

View file

@ -0,0 +1,61 @@
/**
* ProgressionPremium Sprint 3.6c.
*
* Orchestre le contenu de /progression pour un utilisateur Premium :
* - not-ready NotReadyState
* - ready Hero (indice) + PatternsList + PatternExerciceCard[] + footer
*
* Règle L : tokens Direction H exclusivement.
* Règle H : purement présentationnel data vient du parent via props.
*/
import { Card } from '@/shared/ui/Card'
import { formatRelativeDate } from '@/shared/lib/date'
import type { PatternsResponse } from '@/entities/patterns/types'
import { PreparationIndexHero } from './PreparationIndexHero'
import { PatternsList } from './PatternsList'
import { PatternExerciceCard } from './PatternExerciceCard'
import { NotReadyState } from './NotReadyState'
interface Props {
data: PatternsResponse
}
export function ProgressionPremium({ data }: Props) {
if (!data.ready) {
return <NotReadyState current={data.current} minimum={data.minimum} />
}
return (
<div className="space-y-6">
<PreparationIndexHero index={data.preparation_index} />
<section aria-label="Erreurs récurrentes">
<h2 className="mb-3 text-base font-semibold text-ink-1">
Erreurs récurrentes
</h2>
<PatternsList patterns={data.patterns} />
</section>
{data.exercises.length > 0 && (
<section aria-label="Exercices long terme">
<h2 className="mb-3 text-base font-semibold text-ink-1">
Exercices long terme
</h2>
<div className="space-y-3">
{data.exercises.map((ex, i) => (
<PatternExerciceCard key={`${ex.code}-${i}`} exercice={ex} />
))}
</div>
</section>
)}
<Card variant="default" className="p-3">
<p className="text-center text-xs text-ink-4">
Analyse basée sur vos {data.analyzed_productions} dernières productions {' '}
{formatRelativeDate(data.last_analysis)}
</p>
</Card>
</div>
)
}

View file

@ -0,0 +1,116 @@
/**
* Tests ProgressionPremium (Sprint 3.6c).
*
* Couvre les 3 états principaux du composant (le gating plan lui-même est
* géré en amont par ProgressionPage via hasAccess).
*/
import { describe, it, expect, afterEach } from 'vitest'
import { render, screen, cleanup } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { ProgressionPremium } from '../ProgressionPremium'
import type {
PatternsReady,
PatternsNotReady,
PatternExercice,
} from '@/entities/patterns/types'
afterEach(cleanup)
function renderWithRouter(ui: React.ReactNode) {
return render(<MemoryRouter>{ui}</MemoryRouter>)
}
const EXERCICE: PatternExercice = {
code: 'accord_sujet_verbe',
critere: 'competence_grammaticale',
diagnostic: 'Accords fragiles sur vos 5 dernières productions.',
exercice: {
consigne: 'Corrigez la phrase suivante.',
exemple: 'les enfants joue dans le parc',
correction: 'les enfants jouent dans le parc',
astuce: 'Pointez du doigt le sujet avant de lire le verbe.',
},
}
const READY_DATA: PatternsReady = {
ready: true,
patterns: [
{ code: 'accord_sujet_verbe', critere: 'competence_grammaticale', frequency: 4, description: null },
{ code: 'connecteurs_repetes', critere: 'coherence_cohesion', frequency: 3, description: null },
],
exercises: [EXERCICE],
preparation_index: { score: 72, message: 'Vous êtes en bonne voie pour NCLC 9+' },
analyzed_productions: 5,
last_analysis: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
}
const NOT_READY: PatternsNotReady = {
ready: false,
minimum: 5,
current: 3,
}
describe('ProgressionPremium — état not-ready', () => {
it('affiche le compteur N/5 et le CTA "Démarrer une simulation"', () => {
renderWithRouter(<ProgressionPremium data={NOT_READY} />)
expect(screen.getByText(/3\/5/)).toBeInTheDocument()
expect(screen.getByText(/encore 2 pour débloquer votre profil/i)).toBeInTheDocument()
expect(screen.getByRole('link', { name: /démarrer une simulation/i })).toHaveAttribute(
'href',
'/simulation/ee',
)
})
})
describe('ProgressionPremium — état ready', () => {
it('affiche l\'indice de préparation (score + message)', () => {
renderWithRouter(<ProgressionPremium data={READY_DATA} />)
expect(screen.getByText('72')).toBeInTheDocument()
expect(screen.getByText(/NCLC 9/)).toBeInTheDocument()
})
it('affiche les 2 patterns avec leur fréquence', () => {
renderWithRouter(<ProgressionPremium data={READY_DATA} />)
expect(screen.getByText('4/5')).toBeInTheDocument()
expect(screen.getByText('3/5')).toBeInTheDocument()
// Libellés critères — chacun apparaît au moins une fois (pattern + exercice
// réutilisent le même label, donc getAllByText)
expect(screen.getAllByText(/Compétence grammaticale/i).length).toBeGreaterThan(0)
expect(screen.getByText(/Cohérence et cohésion/i)).toBeInTheDocument()
})
it('rend l\'exercice avec consigne, exemple incorrect, correction et astuce', () => {
renderWithRouter(<ProgressionPremium data={READY_DATA} />)
expect(screen.getByText(EXERCICE.exercice.consigne)).toBeInTheDocument()
expect(screen.getByText(EXERCICE.exercice.exemple)).toBeInTheDocument()
expect(screen.getByText(EXERCICE.exercice.correction)).toBeInTheDocument()
expect(screen.getByText(EXERCICE.exercice.astuce)).toBeInTheDocument()
expect(screen.getByText(/astuce de relecture/i)).toBeInTheDocument()
})
it('affiche le footer "Analyse basée sur vos 5 dernières productions"', () => {
renderWithRouter(<ProgressionPremium data={READY_DATA} />)
expect(
screen.getByText(/analyse basée sur vos 5 dernières productions/i),
).toBeInTheDocument()
})
it('ready sans pattern : affiche le message "Aucune erreur récurrente"', () => {
const noPatterns: PatternsReady = {
...READY_DATA,
patterns: [],
exercises: [],
}
renderWithRouter(<ProgressionPremium data={noPatterns} />)
expect(screen.getByText(/aucune erreur récurrente détectée/i)).toBeInTheDocument()
// Pas de section "Exercices long terme" si exercises=[]
expect(screen.queryByText(/exercices long terme/i)).not.toBeInTheDocument()
})
})

View file

@ -0,0 +1,31 @@
/**
* Hook TanStack Query analyse des patterns (Premium).
*
* Clé `['users', 'patterns']` partagée entre `/progression` et la section
* dashboard Premium un seul appel backend pour les deux affichages.
*
* `staleTime: 60 s` l'analyse ne change que quand une nouvelle production est
* corrigée ; 60 s évite les rafraîchissements inutiles.
*
* `enabled` : ne lance la requête QUE si l'utilisateur a la feature. Évite un
* 403 parasite pour Free/Standard (la route backend refuse avec
* PLAN_INSUFFICIENT on court-circuite côté client).
*
* Règle H : aucune logique métier ici wrap pur autour de `getPatterns`.
* Règle D : le check feature utilise `hasAccess`, jamais `plan === 'premium'`.
*/
import { useQuery } from '@tanstack/react-query'
import { getPatterns } from '@/entities/patterns/api'
import { hasAccess, type Plan } from '@/entities/user/lib'
export function usePatterns(plan: Plan | undefined) {
const enabled = plan !== undefined && hasAccess(plan, 'pattern_analysis')
return useQuery({
queryKey: ['users', 'patterns'] as const,
queryFn: getPatterns,
staleTime: 60 * 1000,
enabled,
})
}

View file

@ -0,0 +1,76 @@
/**
* Page /progression Sprint 3.6c.
*
* Gating plan via `hasAccess(plan, 'pattern_analysis')` :
* - Free + Standard `BlurredProgression` (aperçu flouté + CTA upgrade)
* - Premium `ProgressionPremium` (NotReady ou contenu complet)
*
* Règle D : aucun `plan === 'xxx'` tout passe par hasAccess().
* Règle L : tokens Direction H exclusivement.
*/
import { useNavigate } from 'react-router-dom'
import { Card } from '@/shared/ui/Card'
import { Button } from '@/shared/ui/Button'
import { hasAccess } from '@/entities/user/lib'
import { usePlan } from '@/features/dashboard/hooks/usePlan'
import { usePatterns } from '../hooks/usePatterns'
import { BlurredProgression } from '../components/BlurredProgression'
import { ProgressionPremium } from '../components/ProgressionPremium'
function Skeleton() {
return (
<div className="space-y-4" aria-busy="true" aria-label="Chargement de votre profil…">
<div className="h-32 animate-pulse rounded-lg bg-canvas-2" />
<div className="h-24 animate-pulse rounded-lg bg-canvas-2" />
<div className="h-48 animate-pulse rounded-lg bg-canvas-2" />
</div>
)
}
export function ProgressionPage() {
const navigate = useNavigate()
const { data: planData, isLoading: isPlanLoading } = usePlan()
const { data: patternsData, isLoading: isPatternsLoading, isError } = usePatterns(
planData?.plan,
)
const isPremium = planData ? hasAccess(planData.plan, 'pattern_analysis') : false
return (
<main className="mx-auto max-w-3xl space-y-6 px-4 py-6">
<header className="space-y-1">
<h1 className="text-lg font-semibold text-ink-1">Profil de préparation</h1>
<p className="text-sm text-ink-3">
Repérez vos erreurs récurrentes et travaillez-les avec des exercices ciblés.
</p>
</header>
{isPlanLoading && <Skeleton />}
{!isPlanLoading && planData && !isPremium && (
<BlurredProgression onUpgrade={() => navigate('/plan')} />
)}
{!isPlanLoading && planData && isPremium && (
<>
{isPatternsLoading && <Skeleton />}
{isError && (
<Card variant="default" className="border-l-4 border-l-danger p-4">
<p className="text-sm text-danger" role="alert">
Impossible de charger votre profil de préparation. Réessayez dans
quelques instants.
</p>
<div className="mt-3">
<Button variant="secondary" size="sm" onClick={() => navigate(0)}>
Rafraîchir
</Button>
</div>
</Card>
)}
{patternsData && <ProgressionPremium data={patternsData} />}
</>
)}
</main>
)
}