From a60c2986052b34cd6f40cc4137a95298456f7ca7 Mon Sep 17 00:00:00 2001 From: Hermann_Kitio Date: Wed, 22 Apr 2026 23:12:23 +0300 Subject: [PATCH] =?UTF-8?q?feat(progression):=20page=20/progression=20+=20?= =?UTF-8?q?section=20Dashboard=20Premium=20=E2=80=94=20patterns,=20exercic?= =?UTF-8?q?es=20long=20terme,=20indice=20de=20pr=C3=A9paration=20(Sprint?= =?UTF-8?q?=203.6c)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/CHANGELOG.md | 36 +++++ docs/ROADMAP.md | 17 ++- src/app/router.tsx | 3 +- src/entities/patterns/api.ts | 23 +++ src/entities/patterns/types.ts | 52 +++++++ src/entities/report/lib.ts | 12 ++ .../components/MonProfilPreparation.tsx | 118 +++++++++++++++ .../__tests__/MonProfilPreparation.test.tsx | 141 ++++++++++++++++++ .../dashboard/pages/DashboardPage.tsx | 4 + .../components/BlurredProgression.tsx | 45 ++++++ .../progression/components/NotReadyState.tsx | 62 ++++++++ .../components/PatternExerciceCard.tsx | 97 ++++++++++++ .../progression/components/PatternsList.tsx | 54 +++++++ .../components/PreparationIndexHero.tsx | 64 ++++++++ .../components/ProgressionPremium.tsx | 61 ++++++++ .../__tests__/ProgressionPremium.test.tsx | 116 ++++++++++++++ src/features/progression/hooks/usePatterns.ts | 31 ++++ .../progression/pages/ProgressionPage.tsx | 76 ++++++++++ 18 files changed, 1005 insertions(+), 7 deletions(-) create mode 100644 src/entities/patterns/api.ts create mode 100644 src/entities/patterns/types.ts create mode 100644 src/features/dashboard/components/MonProfilPreparation.tsx create mode 100644 src/features/dashboard/components/__tests__/MonProfilPreparation.test.tsx create mode 100644 src/features/progression/components/BlurredProgression.tsx create mode 100644 src/features/progression/components/NotReadyState.tsx create mode 100644 src/features/progression/components/PatternExerciceCard.tsx create mode 100644 src/features/progression/components/PatternsList.tsx create mode 100644 src/features/progression/components/PreparationIndexHero.tsx create mode 100644 src/features/progression/components/ProgressionPremium.tsx create mode 100644 src/features/progression/components/__tests__/ProgressionPremium.test.tsx create mode 100644 src/features/progression/hooks/usePatterns.ts create mode 100644 src/features/progression/pages/ProgressionPage.tsx diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 8ba7752..4168e9d 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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é). +## [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) ### Added (backend) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 48e0425..bd52462 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -64,12 +64,17 @@ - Helper `formatRelativeDate` (Intl.RelativeTimeFormat, zéro dépendance). - 102 tests frontend verts (+18 vs baseline 84). -## Sprint 3.6c — Analyse patterns (Premium) -- Backend : GET /users/patterns — agrégation SQL erreurs_codes sur 5 dernières productions -- Backend : exercices long terme générés par DeepSeek sur patterns confirmés (≥ 3/5) -- Backend : indice de préparation 0→100 -- Frontend : Dashboard Premium — section "Mon profil de préparation" -- Frontend : erreurs récurrentes + exercices long terme + indice +## Sprint 3.6c — Analyse patterns (Premium) ✅ +- 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 — 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 — formule 60 % score moyen + 20 % régularité + 20 % tendance, messages figés (`<40`, `40-70`, `>70`). +- Backend : migration SQL `005_sprint_3_6c_pattern_analyses.sql` (RLS SELECT par user_id, index composite, CHECK constraints). +- 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) 16. MediaRecorder + upload audio EO T1/T3 diff --git a/src/app/router.tsx b/src/app/router.tsx index e31a88a..4a0df8e 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -9,6 +9,7 @@ import { SimulationPage } from '@/features/simulations/pages/SimulationPage' import { SujetsPage } from '@/features/simulations/pages/SujetsPage' import { RapportPage } from '@/features/simulations/pages/RapportPage' import { HistoriquePage } from '@/features/historique/pages/HistoriquePage' +import { ProgressionPage } from '@/features/progression/pages/ProgressionPage' import { SimulationFlowProvider } from '@/features/simulations/state/SimulationFlowProvider' import { AppLayout } from './AppLayout' @@ -70,7 +71,7 @@ export function AppRouter() { {/* Autres sections — Sprint 4+ */} } /> - } /> + } /> } /> } /> } /> diff --git a/src/entities/patterns/api.ts b/src/entities/patterns/api.ts new file mode 100644 index 0000000..9005b09 --- /dev/null +++ b/src/entities/patterns/api.ts @@ -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 { + return apiFetch('/users/patterns', { + timeoutMs: PATTERNS_TIMEOUT_MS, + }) +} diff --git a/src/entities/patterns/types.ts b/src/entities/patterns/types.ts new file mode 100644 index 0000000..b150ea8 --- /dev/null +++ b/src/entities/patterns/types.ts @@ -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 diff --git a/src/entities/report/lib.ts b/src/entities/report/lib.ts index 4670e42..e88a7e1 100644 --- a/src/entities/report/lib.ts +++ b/src/entities/report/lib.ts @@ -87,3 +87,15 @@ export function ecartVsCible(score: number, nclcCible: number): { } 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 = { + 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', +} diff --git a/src/features/dashboard/components/MonProfilPreparation.tsx b/src/features/dashboard/components/MonProfilPreparation.tsx new file mode 100644 index 0000000..97ff571 --- /dev/null +++ b/src/features/dashboard/components/MonProfilPreparation.tsx @@ -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 ( + +

+ Mon profil de préparation +

+

+ {isError ? 'Profil temporairement indisponible.' : 'Chargement…'} +

+
+ ) + } + + if (!data.ready) { + const remaining = Math.max(0, data.minimum - data.current) + return ( + +

+ Mon profil de préparation +

+

+ Encore{' '} + {remaining}{' '} + {remaining > 1 ? 'simulations' : 'simulation'} pour débloquer votre profil. +

+

+ {data.current}/{data.minimum} simulations corrigées +

+
+ ) + } + + const patternsCount = data.patterns.length + const pct = Math.max(0, Math.min(100, data.preparation_index.score)) + const color = gaugeColor(pct) + + return ( + +
+
+

+ Indice de préparation +

+

+ {data.preparation_index.score} + /100 +

+
+

+ {data.preparation_index.message} +

+
+ +
+
+
+ +

+ {patternsCount === 0 + ? 'Aucune erreur récurrente identifiée — continuez !' + : `${patternsCount} ${patternsCount > 1 ? 'erreurs récurrentes identifiées' : 'erreur récurrente identifiée'}.`} +

+ + + + ) +} diff --git a/src/features/dashboard/components/__tests__/MonProfilPreparation.test.tsx b/src/features/dashboard/components/__tests__/MonProfilPreparation.test.tsx new file mode 100644 index 0000000..66107fb --- /dev/null +++ b/src/features/dashboard/components/__tests__/MonProfilPreparation.test.tsx @@ -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({ui}) +} + +describe('MonProfilPreparation — gating plan', () => { + it('plan free → ne rend rien', () => { + const { container } = renderWithRouter() + expect(container).toBeEmptyDOMElement() + }) + + it('plan standard → ne rend rien', () => { + const { container } = renderWithRouter() + 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) + + renderWithRouter() + + 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) + + renderWithRouter() + + 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) + + renderWithRouter() + + 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) + + renderWithRouter() + + 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) + + renderWithRouter() + + expect(screen.getByText(/aucune erreur récurrente/i)).toBeInTheDocument() + expect(screen.getByText('85')).toBeInTheDocument() + }) +}) diff --git a/src/features/dashboard/pages/DashboardPage.tsx b/src/features/dashboard/pages/DashboardPage.tsx index 2ab7467..b575e88 100644 --- a/src/features/dashboard/pages/DashboardPage.tsx +++ b/src/features/dashboard/pages/DashboardPage.tsx @@ -15,6 +15,7 @@ import { useAuth } from '@/features/auth/hooks/useAuth' import { usePlan } from '../hooks/usePlan' import { PaywallBanner } from '../components/PaywallBanner' import { PLAN_QUERY_KEY } from '../hooks/usePlan' +import { MonProfilPreparation } from '../components/MonProfilPreparation' const PLAN_LABELS: Record = { free: 'Plan Découverte', @@ -120,6 +121,9 @@ export function DashboardPage() {

Dernières simulations

Aucune simulation pour l'instant.

+ + {/* Mon profil de préparation — Premium uniquement (gate via hasAccess) */} +
)} diff --git a/src/features/progression/components/BlurredProgression.tsx b/src/features/progression/components/BlurredProgression.tsx new file mode 100644 index 0000000..fd10f04 --- /dev/null +++ b/src/features/progression/components/BlurredProgression.tsx @@ -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 ( +
+ + ) +} diff --git a/src/features/progression/components/NotReadyState.tsx b/src/features/progression/components/NotReadyState.tsx new file mode 100644 index 0000000..eb8de6c --- /dev/null +++ b/src/features/progression/components/NotReadyState.tsx @@ -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 ( + +
+

Profil de préparation

+

+ Vous avez réalisé{' '} + + {current}/{minimum} + {' '} + simulations corrigées.{' '} + {remaining > 0 + ? `Encore ${remaining} pour débloquer votre profil.` + : 'Votre profil va être généré à la prochaine correction.'} +

+
+ +
+
+
+ +
+ +
+ + ) +} diff --git a/src/features/progression/components/PatternExerciceCard.tsx b/src/features/progression/components/PatternExerciceCard.tsx new file mode 100644 index 0000000..bf0a015 --- /dev/null +++ b/src/features/progression/components/PatternExerciceCard.tsx @@ -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 ( + +
+
+ {critereLabel} + + {exercice.code.replace(/_/g, ' ')} + +
+ {exercice.diagnostic && ( +

+ {children} }} + > + {exercice.diagnostic} + +

+ )} +
+ + {exercice.exercice.consigne && ( +
+

+ Consigne +

+

+ {exercice.exercice.consigne} +

+
+ )} + +
+
+

+ Incorrect +

+

+ {exercice.exercice.exemple} +

+
+
+

+ Correct +

+

+ {exercice.exercice.correction} +

+
+
+ +
+
+
+ ) +} diff --git a/src/features/progression/components/PatternsList.tsx b/src/features/progression/components/PatternsList.tsx new file mode 100644 index 0000000..4813a18 --- /dev/null +++ b/src/features/progression/components/PatternsList.tsx @@ -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 ( + +

+ Aucune erreur récurrente détectée sur vos 5 dernières productions. + Continuez ainsi ! +

+
+ ) + } + + return ( +
    + {patterns.map((p) => ( +
  • + +
    +

    + {p.description ?? humanizeCode(p.code)} +

    +

    {CRITERE_LABELS[p.critere]}

    +
    + + {p.frequency}/5 + +
    +
  • + ))} +
+ ) +} diff --git a/src/features/progression/components/PreparationIndexHero.tsx b/src/features/progression/components/PreparationIndexHero.tsx new file mode 100644 index 0000000..e7cb44d --- /dev/null +++ b/src/features/progression/components/PreparationIndexHero.tsx @@ -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 ( + +
+
+

+ Indice de préparation +

+

+ {index.score} + /100 +

+
+

{index.message}

+
+ +
+
+
+
+ 0 + 40 + 70 + 100 +
+ + ) +} diff --git a/src/features/progression/components/ProgressionPremium.tsx b/src/features/progression/components/ProgressionPremium.tsx new file mode 100644 index 0000000..b654e87 --- /dev/null +++ b/src/features/progression/components/ProgressionPremium.tsx @@ -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 + } + + return ( +
+ + +
+

+ Erreurs récurrentes +

+ +
+ + {data.exercises.length > 0 && ( +
+

+ Exercices long terme +

+
+ {data.exercises.map((ex, i) => ( + + ))} +
+
+ )} + + +

+ Analyse basée sur vos {data.analyzed_productions} dernières productions —{' '} + {formatRelativeDate(data.last_analysis)} +

+
+
+ ) +} diff --git a/src/features/progression/components/__tests__/ProgressionPremium.test.tsx b/src/features/progression/components/__tests__/ProgressionPremium.test.tsx new file mode 100644 index 0000000..05feb05 --- /dev/null +++ b/src/features/progression/components/__tests__/ProgressionPremium.test.tsx @@ -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({ui}) +} + +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() + + 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() + + expect(screen.getByText('72')).toBeInTheDocument() + expect(screen.getByText(/NCLC 9/)).toBeInTheDocument() + }) + + it('affiche les 2 patterns avec leur fréquence', () => { + renderWithRouter() + + 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() + + 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() + + 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() + + 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() + }) +}) diff --git a/src/features/progression/hooks/usePatterns.ts b/src/features/progression/hooks/usePatterns.ts new file mode 100644 index 0000000..348cbc8 --- /dev/null +++ b/src/features/progression/hooks/usePatterns.ts @@ -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, + }) +} diff --git a/src/features/progression/pages/ProgressionPage.tsx b/src/features/progression/pages/ProgressionPage.tsx new file mode 100644 index 0000000..0612d98 --- /dev/null +++ b/src/features/progression/pages/ProgressionPage.tsx @@ -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 ( +
+
+
+
+
+ ) +} + +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 ( +
+
+

Profil de préparation

+

+ Repérez vos erreurs récurrentes et travaillez-les avec des exercices ciblés. +

+
+ + {isPlanLoading && } + + {!isPlanLoading && planData && !isPremium && ( + navigate('/plan')} /> + )} + + {!isPlanLoading && planData && isPremium && ( + <> + {isPatternsLoading && } + {isError && ( + +

+ Impossible de charger votre profil de préparation. Réessayez dans + quelques instants. +

+
+ +
+
+ )} + {patternsData && } + + )} +
+ ) +}