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:
parent
a752029c19
commit
a60c298605
18 changed files with 1005 additions and 7 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 />} />
|
||||||
|
|
|
||||||
23
src/entities/patterns/api.ts
Normal file
23
src/entities/patterns/api.ts
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
52
src/entities/patterns/types.ts
Normal file
52
src/entities/patterns/types.ts
Normal 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
|
||||||
|
|
@ -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',
|
||||||
|
}
|
||||||
|
|
|
||||||
118
src/features/dashboard/components/MonProfilPreparation.tsx
Normal file
118
src/features/dashboard/components/MonProfilPreparation.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
45
src/features/progression/components/BlurredProgression.tsx
Normal file
45
src/features/progression/components/BlurredProgression.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
62
src/features/progression/components/NotReadyState.tsx
Normal file
62
src/features/progression/components/NotReadyState.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
97
src/features/progression/components/PatternExerciceCard.tsx
Normal file
97
src/features/progression/components/PatternExerciceCard.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
54
src/features/progression/components/PatternsList.tsx
Normal file
54
src/features/progression/components/PatternsList.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
64
src/features/progression/components/PreparationIndexHero.tsx
Normal file
64
src/features/progression/components/PreparationIndexHero.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
61
src/features/progression/components/ProgressionPremium.tsx
Normal file
61
src/features/progression/components/ProgressionPremium.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
31
src/features/progression/hooks/usePatterns.ts
Normal file
31
src/features/progression/hooks/usePatterns.ts
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
76
src/features/progression/pages/ProgressionPage.tsx
Normal file
76
src/features/progression/pages/ProgressionPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue