refactor(simulation-ee): Sprint 3.5 clean — FTD-17/18/19 résolus, factorisation SimulationForm

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hermann_Kitio 2026-04-22 14:03:46 +03:00
parent 385b29679e
commit 18f92098cb
11 changed files with 36 additions and 66 deletions

View file

@ -179,46 +179,7 @@ Vient du pattern `c.json(result, result.status)` où `result` contient déjà `s
--- ---
### FTD-17 — Clé `['plan']` dupliquée entre features > FTD-17, FTD-18, FTD-19 résolus au Sprint 3.5 (2026-04-22) — voir §5 Historique des résolutions.
**Priorité :** 🟢 Mineur
**Statut :** Ouvert — accepté par design (Sprint 3)
**Estimation de session :** 30 min
**Description :** La queryKey TanStack Query `['plan']` est écrite littéralement dans deux endroits : `features/dashboard/hooks/usePlan.ts` (via `PLAN_QUERY_KEY`) et `features/simulations/pages/SimulationPage.tsx` (inline). TanStack Query partage bien le cache par clé identique, donc la déduplication fonctionne en pratique. Mais toute faute de frappe dans l'une des occurrences briserait silencieusement la mise en cache partagée.
**À faire :**
- Déplacer `PLAN_QUERY_KEY` vers `src/entities/user/api.ts` (ou un nouveau `src/entities/user/query-keys.ts`)
- Importer `PLAN_QUERY_KEY` dans `usePlan.ts` et `SimulationPage.tsx` depuis ce fichier partagé
- Supprimer la constante locale dans `usePlan.ts`
**Condition de résolution :** avant l'ajout d'une troisième feature qui consomme `['plan']` (T2 Live page, rapport page).
---
### FTD-18 — SimulationForm utilise encore shadcn Button
**Priorité :** 🟢 Mineur
**Statut :** Ouvert
**Estimation de session :** 30 min
**Description :** `src/features/simulations/components/SimulationForm.tsx` importe `Button` depuis `@/shared/components/ui/button` (shadcn). Depuis Sprint 0.5 bis, la primitive canonique est `@/shared/ui/Button`. SimulationForm a été volontairement exclu de l'étape D1 pour respecter la règle max 3 fichiers par étape.
**À faire :**
- Remplacer l'import shadcn par `@/shared/ui/Button`
- Adapter `variant` si nécessaire (`"outline"``"secondary"`)
**Condition de résolution :** session dédiée — à grouper avec d'autres migrations shadcn → shared/ui si elles surviennent.
---
### FTD-19 — Token `--shadow-focus` absent de `src/index.css`
**Priorité :** 🟢 Mineur
**Statut :** Ouvert
**Estimation de session :** 15 min
**Description :** `DESIGN_SYSTEM.md` spécifie un token `--shadow-focus` pour les états focus des composants interactifs. Ce token n'a jamais été ajouté à `src/index.css`. En attendant, le focus est rendu via `ring-2 ring-expria/20` (token Direction H valide), utilisé dans `Button`, `Card`, `MobileHeader`. Le rendu visuel est correct mais ne correspond pas au token documenté.
**À faire :**
- Ajouter `--shadow-focus` dans `@theme {}` de `src/index.css` (valeur à aligner avec `DESIGN_SYSTEM.md`)
- Remplacer `ring-2 ring-expria/20` par `shadow-focus` dans les composants concernés
**Condition de résolution :** session design system dédiée (peut être groupée avec FTD-15 ou une refonte de tokens).
--- ---
@ -366,6 +327,9 @@ Frontend :
| FTD-13 | Incompatibilité Vitest 3 / Vite 8 (conflit de types `Plugin<any>` entre le Vite 8 top-level avec Rolldown et le Vite 7 pinné de Vitest 3.2.4 ; `npm run build` cassé) | 2026-04-17 | Résolu par upgrade Vitest `3.2.4 → 4.1.4` (et `@vitest/coverage-v8` idem) à l'étape 12-bis du Sprint 0. Vitest 4.x supporte nativement Vite 8 Rolldown. Correctif complémentaire : script `typecheck` passé de `tsc --noEmit -p tsconfig.app.json` à `tsc -b --noEmit` pour couvrir aussi `tsconfig.node.json` (d'où `vite.config.ts`) et éviter qu'un bug similaire échappe à la CI. | | FTD-13 | Incompatibilité Vitest 3 / Vite 8 (conflit de types `Plugin<any>` entre le Vite 8 top-level avec Rolldown et le Vite 7 pinné de Vitest 3.2.4 ; `npm run build` cassé) | 2026-04-17 | Résolu par upgrade Vitest `3.2.4 → 4.1.4` (et `@vitest/coverage-v8` idem) à l'étape 12-bis du Sprint 0. Vitest 4.x supporte nativement Vite 8 Rolldown. Correctif complémentaire : script `typecheck` passé de `tsc --noEmit -p tsconfig.app.json` à `tsc -b --noEmit` pour couvrir aussi `tsconfig.node.json` (d'où `vite.config.ts`) et éviter qu'un bug similaire échappe à la CI. |
| FTD-16 | `VITE_MAINTENANCE_MODE` non lu dans le code — la variable d'env était dans `env.ts` mais jamais consommée | 2026-04-18 | Résolu au Sprint 1 étape 6. Ajout de `isMaintenanceMode` dans `src/shared/config/env.ts` et garde dans `src/app/main.tsx` : `isMaintenanceMode ? <MaintenancePage /> : <Providers />`. `MaintenancePage` est statique (aucun provider requis), tokens Direction H exclusivement. | | FTD-16 | `VITE_MAINTENANCE_MODE` non lu dans le code — la variable d'env était dans `env.ts` mais jamais consommée | 2026-04-18 | Résolu au Sprint 1 étape 6. Ajout de `isMaintenanceMode` dans `src/shared/config/env.ts` et garde dans `src/app/main.tsx` : `isMaintenanceMode ? <MaintenancePage /> : <Providers />`. `MaintenancePage` est statique (aucun provider requis), tokens Direction H exclusivement. |
| FTD-22 | Code orphelin suite à la refonte UX `/sujets` (2026-04-21) — composant `SujetSelector` et helper `selectSujet` plus référencés après bascule dropdown → page dédiée | 2026-04-21 | Résolu **partiellement**. Supprimé : `src/features/simulations/components/SujetSelector.tsx` + helper `selectSujet` de `useSimulation.ts` (les tests `useSimulation.test.tsx` adaptés en utilisant `changeSubject` + `setStep('task-selected')` via `useSimulationFlow`). **Conservés intentionnellement** : le step `'choosing-subject'` (utilisé par `SimulationFlowProvider.selectTask` pour les tâches avec catalogue et par `SimulationPage` pour naviguer vers `/sujets`) et le helper `goToSubjectPicker` (bouton "Changer de sujet" dans `SimulationForm`). | | FTD-22 | Code orphelin suite à la refonte UX `/sujets` (2026-04-21) — composant `SujetSelector` et helper `selectSujet` plus référencés après bascule dropdown → page dédiée | 2026-04-21 | Résolu **partiellement**. Supprimé : `src/features/simulations/components/SujetSelector.tsx` + helper `selectSujet` de `useSimulation.ts` (les tests `useSimulation.test.tsx` adaptés en utilisant `changeSubject` + `setStep('task-selected')` via `useSimulationFlow`). **Conservés intentionnellement** : le step `'choosing-subject'` (utilisé par `SimulationFlowProvider.selectTask` pour les tâches avec catalogue et par `SimulationPage` pour naviguer vers `/sujets`) et le helper `goToSubjectPicker` (bouton "Changer de sujet" dans `SimulationForm`). |
| FTD-17 | Clé `['plan']` dupliquée entre features (`usePlan`, `SimulationPage`, `RapportPage`) | 2026-04-22 | Résolu au Sprint 3.5. Création de `src/entities/user/query-keys.ts` (constantes pures, aucun import runtime) exportant `PLAN_QUERY_KEY = ['plan'] as const`. `features/dashboard/hooks/usePlan.ts` l'importe et le re-exporte pour conserver la rétro-compatibilité de l'import `PLAN_QUERY_KEY`. `SimulationPage.tsx` et `RapportPage.tsx` remplacent leur `useQuery` inline par le hook `usePlan()` — dédup totale de la clé et de la config staleTime. |
| FTD-18 | SimulationForm utilise encore le shadcn Button au lieu de la primitive `@/shared/ui/Button` | 2026-04-22 | Résolu au Sprint 3.5. Remplacement de l'import `@/shared/components/ui/button` par `@/shared/ui/Button` dans `SimulationForm.tsx`. Aucun variant à adapter (usage du Button sans prop `variant``primary` par défaut dans les deux implémentations). Les 7 autres consommateurs shadcn (`Login/RegisterPage`, `PaywallBanner`, `DesignSystemPage`, `ThemeToggle`, `dialog.tsx`) restent hors scope de cette FTD. |
| FTD-19 | Token `--shadow-focus` absent de `src/index.css` | 2026-04-22 | Résolu au Sprint 3.5. Ajout de `--shadow-focus: 0 0 0 3px rgba(27, 79, 216, 0.18)` dans `@theme {}` (valeur conforme à `DESIGN_SYSTEM.md §2`) et `--shadow-focus: 0 0 0 3px rgba(91, 127, 255, 0.32)` dans `.dark {}` (recalculé sur la teinte expria dark `#5B7FFF`). Tailwind 4 génère automatiquement l'utility `shadow-focus`. Migration de 5 occurrences `ring-2 ring-expria/20``shadow-focus` dans `Button.tsx`, `Card.tsx`, `SimulationForm.tsx` (×3), `SpecialCharsKeyboard.tsx`. Factorisation bonus : className dupliquée des boutons secondaires de `SimulationForm` extraite en const `secondaryActionBtn`. |
--- ---
@ -384,3 +348,4 @@ Frontend :
| 1.8 | 2026-04-20 | Ajout FTD-21 🔴 (persistance session simulation — prod + sujet perdus au refresh, session dédiée après G1-G5) | | 1.8 | 2026-04-20 | Ajout FTD-21 🔴 (persistance session simulation — prod + sujet perdus au refresh, session dédiée après G1-G5) |
| 1.9 | 2026-04-21 | FTD-22 résolu partiellement (nettoyage code orphelin refonte `/sujets``SujetSelector` + `selectSujet` supprimés ; `choosing-subject` + `goToSubjectPicker` conservés) | | 1.9 | 2026-04-21 | FTD-22 résolu partiellement (nettoyage code orphelin refonte `/sujets``SujetSelector` + `selectSujet` supprimés ; `choosing-subject` + `goToSubjectPicker` conservés) |
| 1.10 | 2026-04-21 | FTD-21 résolu partiellement pour `/simulation/ee` (autosave 30 s + `beforeunload` + reprise via `localStorage` + `PATCH /:id/contenu` + `PATCH /:id/sujet` + `getById` tolère `rapport=null`) ; EO + examen restent ouverts | | 1.10 | 2026-04-21 | FTD-21 résolu partiellement pour `/simulation/ee` (autosave 30 s + `beforeunload` + reprise via `localStorage` + `PATCH /:id/contenu` + `PATCH /:id/sujet` + `getById` tolère `rapport=null`) ; EO + examen restent ouverts |
| 1.11 | 2026-04-22 | Sprint 3.5 Clean — FTD-17, FTD-18, FTD-19 résolus. 15 FTD actives restantes (cap de 15 respecté) |

View file

@ -0,0 +1,13 @@
/**
* Clés TanStack Query partagées pour le domaine `user`.
*
* Source unique importée par `features/dashboard/hooks/usePlan`,
* `features/simulations/pages/SimulationPage`, `features/simulations/pages/RapportPage`,
* et tout futur consommateur du statut de plan. Une clé locale inline briserait
* silencieusement le cache partagé de TanStack Query à la moindre faute de frappe.
*
* Ce module ne contient que des constantes pures aucun import React, TanStack
* ou autre dépendance runtime.
*/
export const PLAN_QUERY_KEY = ['plan'] as const

View file

@ -12,8 +12,9 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { getPlanStatus } from '@/entities/user/api' import { getPlanStatus } from '@/entities/user/api'
import { PLAN_QUERY_KEY } from '@/entities/user/query-keys'
export const PLAN_QUERY_KEY = ['plan'] as const export { PLAN_QUERY_KEY }
export function usePlan() { export function usePlan() {
return useQuery({ return useQuery({

View file

@ -13,7 +13,7 @@
import { useEffect, useRef, useState, type FormEvent } from 'react' import { useEffect, useRef, useState, type FormEvent } from 'react'
import { Clock, Lightbulb, Loader2, Shuffle } from 'lucide-react' import { Clock, Lightbulb, Loader2, Shuffle } from 'lucide-react'
import { z } from 'zod' import { z } from 'zod'
import { Button } from '@/shared/components/ui/button' import { Button } from '@/shared/ui/Button'
import { formatTache } from '@/entities/production/lib' import { formatTache } from '@/entities/production/lib'
import { hasAccess, type Plan } from '@/entities/user/lib' import { hasAccess, type Plan } from '@/entities/user/lib'
import type { SujetData, Tache } from '@/entities/production/types' import type { SujetData, Tache } from '@/entities/production/types'
@ -32,6 +32,9 @@ import { IdeesSuggestions } from './IdeesSuggestions'
const MIN_WORDS_IDEES = 30 const MIN_WORDS_IDEES = 30
const LS_SIMULATION_ID_KEY = 'expria_simulation_id' const LS_SIMULATION_ID_KEY = 'expria_simulation_id'
const secondaryActionBtn =
'inline-flex items-center gap-1.5 rounded-md border border-line bg-surface px-3 py-1.5 text-sm text-ink-2 transition-colors hover:border-expria hover:text-expria focus:border-expria focus:outline-none focus:shadow-focus disabled:cursor-not-allowed disabled:opacity-50'
const textSchema = z.object({ const textSchema = z.object({
texte: z texte: z
.string() .string()
@ -212,7 +215,7 @@ export function SimulationForm({
onClick={handleIdeesClick} onClick={handleIdeesClick}
disabled={ideesDisabled} disabled={ideesDisabled}
title={ideesTitle} title={ideesTitle}
className="inline-flex items-center gap-1.5 rounded-md border border-line bg-surface px-3 py-1.5 text-sm text-ink-2 transition-colors hover:border-expria hover:text-expria focus:border-expria focus:outline-none focus:ring-2 focus:ring-expria/20 disabled:cursor-not-allowed disabled:opacity-50" className={secondaryActionBtn}
aria-label="Obtenir des suggestions d'idées" aria-label="Obtenir des suggestions d'idées"
> >
<Lightbulb className="size-4" aria-hidden="true" /> <Lightbulb className="size-4" aria-hidden="true" />
@ -222,7 +225,7 @@ export function SimulationForm({
type="button" type="button"
onClick={onChangeSujet} onClick={onChangeSujet}
disabled={isSubmitting} disabled={isSubmitting}
className="inline-flex items-center gap-1.5 rounded-md border border-line bg-surface px-3 py-1.5 text-sm text-ink-2 transition-colors hover:border-expria hover:text-expria focus:border-expria focus:outline-none focus:ring-2 focus:ring-expria/20 disabled:cursor-not-allowed disabled:opacity-50" className={secondaryActionBtn}
aria-label="Changer de sujet" aria-label="Changer de sujet"
> >
<Shuffle className="size-4" aria-hidden="true" /> <Shuffle className="size-4" aria-hidden="true" />
@ -302,7 +305,7 @@ export function SimulationForm({
placeholder="Rédigez votre texte ici…" placeholder="Rédigez votre texte ici…"
aria-invalid={!!fieldError} aria-invalid={!!fieldError}
aria-describedby={fieldError ? 'texte-error' : undefined} aria-describedby={fieldError ? 'texte-error' : undefined}
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:ring-2 focus:ring-expria/20 disabled:cursor-not-allowed disabled:opacity-50" 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"
/> />
<WordCountBar count={wordCount} config={config} /> <WordCountBar count={wordCount} config={config} />
{autosave.savedAt && !fieldError && ( {autosave.savedAt && !fieldError && (

View file

@ -37,7 +37,7 @@ export function SpecialCharsKeyboard({ onInsert, disabled = false }: Props) {
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
onClick={() => onInsert(char)} onClick={() => onInsert(char)}
aria-label={`Insérer le caractère ${char}`} aria-label={`Insérer le caractère ${char}`}
className="size-8 shrink-0 rounded-md border border-line bg-surface text-sm font-medium text-ink-1 transition-colors hover:border-expria hover:bg-expria-50 hover:text-expria focus:border-expria focus:outline-none focus:ring-2 focus:ring-expria/20 disabled:cursor-not-allowed disabled:opacity-50" className="size-8 shrink-0 rounded-md border border-line bg-surface text-sm font-medium text-ink-1 transition-colors hover:border-expria hover:bg-expria-50 hover:text-expria focus:border-expria focus:outline-none focus:shadow-focus disabled:cursor-not-allowed disabled:opacity-50"
> >
{char} {char}
</button> </button>

View file

@ -5,7 +5,6 @@
* staleTime Infinity : un rapport ne change jamais après correction. * staleTime Infinity : un rapport ne change jamais après correction.
* *
* Règle H : aucune logique métier expose les données brutes. * Règle H : aucune logique métier expose les données brutes.
* FTD-17 : queryKey ['plan'] déjà utilisé dans SimulationPage ['rapport', id] est distinct.
*/ */
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'

View file

@ -14,9 +14,8 @@
import { useEffect } from 'react' import { useEffect } from 'react'
import ReactMarkdown from 'react-markdown' import ReactMarkdown from 'react-markdown'
import { Link, useNavigate, useParams } from 'react-router-dom' import { Link, useNavigate, useParams } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { Lock } from 'lucide-react' import { Lock } from 'lucide-react'
import { getPlanStatus } from '@/entities/user/api' import { usePlan } from '@/features/dashboard/hooks/usePlan'
import { isSectionVisible } from '@/entities/report/lib' import { isSectionVisible } from '@/entities/report/lib'
import { useRapport } from '../hooks/useRapport' import { useRapport } from '../hooks/useRapport'
import { Card } from '@/shared/ui/Card' import { Card } from '@/shared/ui/Card'
@ -130,11 +129,7 @@ export function RapportPage() {
data: planData, data: planData,
isLoading: isPlanLoading, isLoading: isPlanLoading,
isError: isPlanError, isError: isPlanError,
} = useQuery({ } = usePlan()
queryKey: ['plan'],
queryFn: getPlanStatus,
staleTime: 5 * 60 * 1000,
})
const onUpgrade = () => navigate('/plan') const onUpgrade = () => navigate('/plan')

View file

@ -6,15 +6,11 @@
* *
* Règle D : quotas et permissions passent par canSimulate() jamais de plan === '...' * Règle D : quotas et permissions passent par canSimulate() jamais de plan === '...'
* Règle H : aucune logique métier tout est dans useSimulation() et les entités. * Règle H : aucune logique métier tout est dans useSimulation() et les entités.
*
* Nota : queryKey ['plan'] est dupliqué depuis features/dashboard/hooks/usePlan.
* TanStack Query partage le cache par clé FTD-17.
*/ */
import { useEffect } from 'react' import { useEffect } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query' import { usePlan } from '@/features/dashboard/hooks/usePlan'
import { getPlanStatus } from '@/entities/user/api'
import { Button } from '@/shared/ui/Button' import { Button } from '@/shared/ui/Button'
import { useSimulation } from '../hooks/useSimulation' import { useSimulation } from '../hooks/useSimulation'
import { TaskSelector } from '../components/TaskSelector' import { TaskSelector } from '../components/TaskSelector'
@ -41,11 +37,7 @@ export function SimulationPage() {
isLoading: isPlanLoading, isLoading: isPlanLoading,
isError: isPlanError, isError: isPlanError,
refetch: refetchPlan, refetch: refetchPlan,
} = useQuery({ } = usePlan()
queryKey: ['plan'],
queryFn: getPlanStatus,
staleTime: 5 * 60 * 1000,
})
const { const {
step, step,

View file

@ -55,6 +55,7 @@
--shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.04); --shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.04);
--shadow-md: 0 4px 12px rgba(15, 23, 42, 0.06), 0 1px 3px rgba(15, 23, 42, 0.04); --shadow-md: 0 4px 12px rgba(15, 23, 42, 0.06), 0 1px 3px rgba(15, 23, 42, 0.04);
--shadow-lg: 0 12px 28px rgba(15, 23, 42, 0.08), 0 2px 6px rgba(15, 23, 42, 0.04); --shadow-lg: 0 12px 28px rgba(15, 23, 42, 0.08), 0 2px 6px rgba(15, 23, 42, 0.04);
--shadow-focus: 0 0 0 3px rgba(27, 79, 216, 0.18);
} }
/* ─── Dark mode — override des tokens couleur et ombres ──────────── */ /* ─── Dark mode — override des tokens couleur et ombres ──────────── */
@ -94,6 +95,7 @@
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.4); --shadow-md: 0 4px 16px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.5); --shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.5);
--shadow-focus: 0 0 0 3px rgba(91, 127, 255, 0.32);
} }
/* ─── Rendu sub-pixel global (non couvert par Tailwind) ──────────── */ /* ─── Rendu sub-pixel global (non couvert par Tailwind) ──────────── */

View file

@ -58,7 +58,7 @@ export function Button({
className={cn( className={cn(
// base // base
'inline-flex cursor-pointer items-center justify-center font-medium transition-colors duration-150', 'inline-flex cursor-pointer items-center justify-center font-medium transition-colors duration-150',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-expria/20', 'focus-visible:outline-none focus-visible:shadow-focus',
'disabled:cursor-not-allowed disabled:opacity-50', 'disabled:cursor-not-allowed disabled:opacity-50',
variantClasses[variant], variantClasses[variant],
sizeClasses[size], sizeClasses[size],

View file

@ -35,7 +35,7 @@ const variantClasses: Record<CardVariant, string> = {
default : 'shadow-sm', default : 'shadow-sm',
raised : 'shadow-md', raised : 'shadow-md',
interactive: interactive:
'shadow-sm cursor-pointer transition-colors duration-150 hover:border-expria hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-expria/20', 'shadow-sm cursor-pointer transition-colors duration-150 hover:border-expria hover:bg-surface-hover focus-visible:outline-none focus-visible:shadow-focus',
} }
export function Card({ variant = 'default', className, children, onClick }: CardProps) { export function Card({ variant = 'default', className, children, onClick }: CardProps) {