diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 38168bd..210cb2b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -51,6 +51,53 @@ 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.6b — Qualité correction — Frontend + +### Added +- `NclcCibleSelector` (segmented control NCLC 9 / NCLC 10) dans `SimulationForm` — valeur propagée au payload `POST /corrections/ee` via `SimulationFlowProvider.submitText(texte, nclcCible)`. +- Composants `rapport/` dans `features/simulations/components/` : + - `ScoreHero` — score /20, jauge avec marqueur du seuil NCLC cible, écart vs objectif (« X points avant NCLC 9 »), badges NCLC atteint / cible. + - `RevelationCards` — 3 colonnes : ce que le candidat croit / ce que le correcteur observe / conséquence. + - `DiagnosticCallout` — callout « Ce qui freine votre progression ». + - `CritereCard` — carte enrichie par critère (exemple / suggestion / astuce + badges codes taxonomie). + - `ConseilNclcCallout` — plan d'action NCLC (objectif, écart, action prioritaire). + - `ExerciceInteractive` — carte exercice avec zone texte, bouton Indice (révélé une fois), bouton « Voir la correction » (activé après saisie), explication. + - `ProductionModeleSection` — texte final + notes pédagogiques + transformations original/amélioré + message encourageant. + - `JobStatusFallback` — fallback pour `exercices_status` / `modele_status` en `'pending'` ou `'error'`. +- Helpers dans `entities/report/lib.ts` : `groupErreursByCritere`, `ecartVsCible`, `critereCodeFromNom`. +- Tests `ExerciceInteractive.test.tsx` (6 tests) — couvre état interne : révélation unique indice, activation bouton correction, affichage correction + explication. +- FTD-24 🟡 dans `TECH_DEBT.md` — polling automatique pour exercices/modèle `pending` (refresh manuel en MVP). + +### Changed +- `entities/report/types.ts` — refonte complète alignée sur le backend Sprint 3.6a : `Report` remplace l'ancien (revelation, diagnostic, criteres enrichis, conseil_nclc, erreurs_codes top-level, exercices dynamiques, modele structuré, statuts pending/ready/error). Suppression de `feedback_court`, `erreurs[]`, `modele:string`, `idees[]` (obsolètes). +- `entities/report/lib.ts` — `BlurableSection` réduite à `'criteres' | 'exercices' | 'modele'` : `revelation`, `diagnostic`, `conseil_nclc` deviennent visibles pour tous les plans conformément à PLANS_TARIFAIRES.md §2. +- `entities/production/types.ts` — `SimulationState` étendu avec `nclc_cible`, `exercices`, `exercices_status`, `modele`, `modele_status` ; `SimulationRapport` aligné sur `CorrectionRapport` backend. +- `entities/report/api.ts` — `getReport` recombine `SimulationState.rapport` + `exercices` + `modele` + statuts en un `Report` unifié pour `useRapport`. +- `RapportPage.tsx` — réécriture complète : câble tous les nouveaux composants, branche le gating plan via `isSectionVisible`, affiche `JobStatusFallback` pour les jobs asynchrones. Résout l'écran blanc post-Sprint 3.6a. +- `floutage.test.ts` réécrit (17 tests — matrice de visibilité + helpers lib). + +### Fixed +- **Race condition `modele_status`** (backend) : l'update principal de correction écrasait `modele_status='ready'` déjà posé par `runModeleJob` (lancé en parallèle option b). `correctionController.correctEE` ne touche plus aux colonnes `*_status` — pilotées exclusivement par les jobs asynchrones. +- **Boucle infinie retour rapport → SimulationPage** : le useEffect sticky `step === 'done' → navigate('/rapport/:id')` renvoyait l'utilisateur sur le rapport à chaque tentative de retour vers `/simulation/ee`. Supprimé ; la navigation initiale vers `/rapport/:id` est déclenchée une seule fois dans `correctMutation.onSuccess` du provider. +- **Boucle retour /sujets → SimulationPage** : même pattern sticky pour `step === 'choosing-subject' → navigate('/sujets')`. Supprimé ; navigation initiale vers `/sujets` déplacée dans `createMutation.onSuccess`. +- **RapportPage hors SimulationFlowProvider** : la route `/rapport/:id` n'était pas sous `SimulationFlowLayout` — l'appel à `useSimulation()` depuis RapportPage throw. Route déplacée sous le layout, l'instance du provider est partagée avec `/simulation/ee` et `/sujets`. + +### Added +- Bouton « Nouvelle simulation » en bas de `RapportPage` qui `reset()` + `navigate('/simulation/ee')`. +- `reset()` explicite dans le bouton « ← Retour » de `SujetsPage` avant la navigation, pour empêcher tout re-déclenchement de la garde sticky. + +### Changed +- Navigations post-mutation déplacées dans `onSuccess` du provider (pattern cohérent pour `createMutation` → `/sujets` et `correctMutation` → `/rapport/:id`). Plus de useEffect réactif aux changements de `step` côté SimulationPage. +- `SujetsPage` : garde étendue de `!production` à `!production \|\| step === 'idle' \|\| step === 'done'` pour couvrir le cas post-rapport (évite le 400 VALIDATION_ERROR sur `PATCH /simulations/:id/sujet` d'une simulation déjà corrigée). +- `RapportPage` breadcrumb : `` remplacé par ` + ) + })} + +

+ {OPTIONS.find((o) => o.value === value)?.hint} +

+ + ) +} diff --git a/src/features/simulations/components/SimulationForm.tsx b/src/features/simulations/components/SimulationForm.tsx index 03ac25c..810f12c 100644 --- a/src/features/simulations/components/SimulationForm.tsx +++ b/src/features/simulations/components/SimulationForm.tsx @@ -17,6 +17,7 @@ import { Button } from '@/shared/ui/Button' import { formatTache } from '@/entities/production/lib' import { hasAccess, type Plan } from '@/entities/user/lib' import type { SujetData, Tache } from '@/entities/production/types' +import type { NclcCible } from '@/entities/report/types' import type { ApiError } from '@/shared/types/api' import { countWords, getSimulationConfig } from '../lib/simulationConfig' import { useTimer } from '../hooks/useTimer' @@ -28,6 +29,7 @@ import { SpecialCharsKeyboard } from './SpecialCharsKeyboard' import { TimerDisplay } from './TimerDisplay' import { WordCountBar } from './WordCountBar' import { IdeesSuggestions } from './IdeesSuggestions' +import { NclcCibleSelector } from './NclcCibleSelector' const MIN_WORDS_IDEES = 30 const LS_SIMULATION_ID_KEY = 'expria_simulation_id' @@ -66,7 +68,7 @@ interface Props { step: SimulationStep isSubmitting: boolean error: ApiError | null - onSubmit: (texte: string) => void + onSubmit: (texte: string, nclcCible: NclcCible) => void onBack: () => void onChangeSujet: () => void } @@ -89,6 +91,7 @@ export function SimulationForm({ const [texte, setTexte] = useState(() => initialContenu ?? '') const [fieldError, setFieldError] = useState(null) const [isIdeesOpen, setIsIdeesOpen] = useState(false) + const [nclcCible, setNclcCible] = useState(9) const config = getSimulationConfig(tache) const wordCount = countWords(texte) @@ -150,8 +153,8 @@ export function SimulationForm({ if (wordCount < config.motsMin) return hasAutoSubmittedRef.current = true - onSubmit(texte) - }, [timer.isExpired, wordCount, config.motsMin, isSubmitting, texte, onSubmit]) + onSubmit(texte, nclcCible) + }, [timer.isExpired, wordCount, config.motsMin, isSubmitting, texte, nclcCible, onSubmit]) function handleInsert(char: string) { const el = textareaRef.current @@ -185,7 +188,7 @@ export function SimulationForm({ return } - onSubmit(parsed.data.texte) + onSubmit(parsed.data.texte, nclcCible) } const apiError = mapCorrectError(error) @@ -308,6 +311,12 @@ export function SimulationForm({ className="w-full resize-none overflow-y-hidden rounded-md border border-line bg-surface p-3 text-sm text-ink-1 placeholder:text-ink-5 focus:border-expria focus:outline-none focus:shadow-focus disabled:cursor-not-allowed disabled:opacity-50" /> + + {autosave.savedAt && !fieldError && (

Sauvegardé à{' '} diff --git a/src/features/simulations/components/rapport/ConseilNclcCallout.tsx b/src/features/simulations/components/rapport/ConseilNclcCallout.tsx new file mode 100644 index 0000000..d0028a8 --- /dev/null +++ b/src/features/simulations/components/rapport/ConseilNclcCallout.tsx @@ -0,0 +1,46 @@ +/** + * ConseilNclcCallout — Sprint 3.6b. + * + * Section "Plan d'action NCLC" : écart au NCLC cible + action prioritaire. + * Visible pour tous les plans. + * + * Règle L : tokens Direction H exclusivement. + */ + +import ReactMarkdown from 'react-markdown' +import { Card } from '@/shared/ui/Card' +import type { ConseilNclc } from '@/entities/report/types' + +interface Props { + conseil: ConseilNclc +} + +export function ConseilNclcCallout({ conseil }: Props) { + return ( +

+

Plan d'action NCLC

+ +
+

+ Objectif +

+

{conseil.nclc_cible}

+

+ Écart +

+

{conseil.ecart}

+
+
+

+ Action prioritaire +

+
+ + {conseil.action_prioritaire} + +
+
+
+
+ ) +} diff --git a/src/features/simulations/components/rapport/CritereCard.tsx b/src/features/simulations/components/rapport/CritereCard.tsx new file mode 100644 index 0000000..4ec443c --- /dev/null +++ b/src/features/simulations/components/rapport/CritereCard.tsx @@ -0,0 +1,80 @@ +/** + * CritereCard — Sprint 3.6b. + * + * Carte critère enrichie : nom, score /5, commentaire, exemple, suggestion, + * astuce + badges des codes d'erreurs taxonomie correspondants. + * + * Visible pour Standard et Premium (gate `detailed_report`). Le floutage est + * géré par le parent via BlurredSection — CritereCard ne connaît pas le plan. + * + * Règle L : tokens Direction H exclusivement. + * Règle H : purement présentationnel — aucune logique plan ici. + */ + +import ReactMarkdown from 'react-markdown' +import { Card } from '@/shared/ui/Card' +import { Badge } from '@/shared/ui/Badge' +import type { Critere, ErreurCode } from '@/entities/report/types' + +interface Props { + critere: Critere + erreursCodes: ErreurCode[] +} + +export function CritereCard({ critere, erreursCodes }: Props) { + return ( + +
+

{critere.nom}

+ + {critere.score}/5 + +
+ + {critere.commentaire && ( +
+ + {critere.commentaire} + +
+ )} + + {critere.exemple && ( +
+

+ Exemple tiré de votre texte +

+

+ « {critere.exemple} » +

+
+ )} + + {critere.suggestion && ( +
+

+ Reformulation suggérée +

+

{critere.suggestion}

+
+ )} + + {critere.astuce && ( +
+ + {critere.astuce} +
+ )} + + {erreursCodes.length > 0 && ( +
+ {erreursCodes.map((e) => ( + + {e.description ?? e.code.replace(/_/g, ' ')} + + ))} +
+ )} +
+ ) +} diff --git a/src/features/simulations/components/rapport/DiagnosticCallout.tsx b/src/features/simulations/components/rapport/DiagnosticCallout.tsx new file mode 100644 index 0000000..97bf160 --- /dev/null +++ b/src/features/simulations/components/rapport/DiagnosticCallout.tsx @@ -0,0 +1,32 @@ +/** + * DiagnosticCallout — Sprint 3.6b. + * + * Section "Ce qui freine votre progression" — phrase courte identifiant + * le frein principal. Visible pour tous les plans. + * + * Règle L : tokens Direction H exclusivement. + */ + +import ReactMarkdown from 'react-markdown' +import { Card } from '@/shared/ui/Card' + +interface Props { + diagnostic: string +} + +export function DiagnosticCallout({ diagnostic }: Props) { + return ( +
+

+ Ce qui freine votre progression +

+ +
+ + {diagnostic} + +
+
+
+ ) +} diff --git a/src/features/simulations/components/rapport/ExerciceInteractive.tsx b/src/features/simulations/components/rapport/ExerciceInteractive.tsx new file mode 100644 index 0000000..4b20fe0 --- /dev/null +++ b/src/features/simulations/components/rapport/ExerciceInteractive.tsx @@ -0,0 +1,143 @@ +/** + * ExerciceInteractive — Sprint 3.6b. + * + * Carte d'exercice avec interactions : + * - Badge de difficulté + thème + diagnostic + * - Consigne + extrait candidat + * - Zone de texte libre (tentative du candidat) + * - Bouton "Indice" → révèle une piste (fond jaune), une seule fois + * - Bouton "Voir la correction" → activé dès qu'une saisie est présente → + * révèle correction (fond vert) + explication + * - Message "Comparez avec votre réponse" une fois la correction révélée + * + * Règle H : aucune logique métier — la correction ne calcule rien, elle + * révèle seulement ce que DeepSeek a produit. + * Règle L : tokens Direction H exclusivement. + */ + +import { useState } from 'react' +import ReactMarkdown from 'react-markdown' +import { Card } from '@/shared/ui/Card' +import { Badge } from '@/shared/ui/Badge' +import { Button } from '@/shared/ui/Button' +import { DIFFICULTE_LABEL, type Exercice } from '@/entities/report/types' + +interface Props { + exercice: Exercice +} + +export function ExerciceInteractive({ exercice }: Props) { + const [tentative, setTentative] = useState('') + const [indiceRevealed, setIndiceRevealed] = useState(false) + const [correctionRevealed, setCorrectionRevealed] = useState(false) + + const canRevealCorrection = tentative.trim().length > 0 + + return ( + +
+
+ {DIFFICULTE_LABEL[exercice.difficulte]} + {exercice.theme && ( + + {exercice.theme.replace(/_/g, ' ')} + + )} +
+
+ + {exercice.diagnostic && ( +

{exercice.diagnostic}

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

+ Consigne +

+

{exercice.consigne}

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

+ Extrait à retravailler +

+

« {exercice.extrait} »

+
+ )} + +