feat(historique): page /historique — liste paginée des productions + gating plan (Sprint 3.7)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
da4e465125
commit
a752029c19
12 changed files with 762 additions and 1 deletions
|
|
@ -51,6 +51,43 @@ 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.7 — Historique (Backend + Frontend)
|
||||||
|
|
||||||
|
### Added (backend)
|
||||||
|
- `GET /simulations` — liste paginée des productions de l'utilisateur connecté.
|
||||||
|
- Query params : `page` (défaut 1, entier ≥ 1), `limit` (défaut 20, entier entre 1 et 50).
|
||||||
|
- Tri : `created_at DESC` côté Supabase.
|
||||||
|
- Filtre : `user_id = profile.id` (double-protection avec RLS).
|
||||||
|
- Projection : `id, tache, mode, score, nclc, nclc_cible, created_at` — champs lourds (`contenu`, `rapport`, `exercices`, `modele`) **exclus**.
|
||||||
|
- Réponse : `{ data: ListItem[], pagination: { page, limit, total } }`.
|
||||||
|
- Erreurs : `400 VALIDATION_ERROR` si `page`/`limit` invalide, `401 AUTH_REQUIRED` si JWT absent, `500 INTERNAL_ERROR` si DB down.
|
||||||
|
- `simulationController.list(options, profile)` + interfaces `ListOptions`, `ListItem`, `ListResult`.
|
||||||
|
- 12 nouveaux tests sur la route `GET /simulations` (186 tests backend verts, +12 vs baseline 174).
|
||||||
|
|
||||||
|
### Added (frontend)
|
||||||
|
- Page `/historique` (route sous `AppLayout` + `ProtectedRoute`, remplace le placeholder `ComingSoon`).
|
||||||
|
- `HistoriquePage` — orchestre `usePlan` + `useSimulationsList`, state local de pagination, gating plan Free via `hasAccess('dashboard')`.
|
||||||
|
- `SimulationsList` — composant liste avec :
|
||||||
|
- Empty state + CTA « Démarrer une simulation » → `/simulation/ee`
|
||||||
|
- Loading skeleton (5 barres animées)
|
||||||
|
- Error state (callout discret `border-l-danger`)
|
||||||
|
- Aperçu flouté Free + bouton `variant="upgrade"` « Passer en Standard »
|
||||||
|
- Pagination Précédent / Suivant (masquée si une seule page)
|
||||||
|
- Affichage « Page X sur Y — Z simulations »
|
||||||
|
- `SimulationListItem` — carte item : date relative, libellé de tâche (`formatTache`), score `/20`, `NCLC atteint / cible`, badges « Examen » et « En cours » (rapport non prêt). Clic → `/rapport/:id`.
|
||||||
|
- `useSimulationsList(page, limit)` — hook TanStack Query, clé `['simulations', 'list', page, limit]`, `staleTime: 30s`, `placeholderData: keepPreviousData` pour éviter le flash de squelette au changement de page.
|
||||||
|
- `listSimulations(page, limit)` dans `entities/production/api.ts` — wrap `apiFetch` + `URLSearchParams`.
|
||||||
|
- Types `SimulationListItem` et `SimulationsListResponse` dans `entities/production/types.ts`.
|
||||||
|
- `src/shared/lib/date.ts` — helper `formatRelativeDate(iso, now?)` basé sur `Intl.RelativeTimeFormat('fr', { numeric: 'auto' })`. Seuils : secondes → minutes → heures → jours → semaines → mois → années. Zéro dépendance.
|
||||||
|
- 18 nouveaux tests frontend (7 `date.test.ts` + 11 `SimulationsList.test.tsx`).
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
- Les simulations avec `score === null` (en cours ou correction échouée) sont **affichées** avec un badge « En cours ». Clic → `/rapport/:id` — `RapportPage` gère le cas `REPORT_NOT_READY` (FTD-21) en redirigeant vers `/simulation/ee`.
|
||||||
|
- `BlurredPreview` dupliqué localement dans `SimulationsList` (pattern équivalent à `BlurredSection` de `RapportPage`). À extraire en `shared/` si le pattern se répète dans un 3ᵉ endroit — pas fait dans ce sprint.
|
||||||
|
- Pagination : Précédent/Suivant (MVP) retenu contre scroll infini. Le choix sera revu si l'historique dépasse 100 items en prod.
|
||||||
|
- Tests frontend : **102/102 verts** (+18 vs baseline 84).
|
||||||
|
|
||||||
|
|
||||||
## [Unreleased] — 2026-04-22 — Sprint 3.6b — Qualité correction — Frontend
|
## [Unreleased] — 2026-04-22 — Sprint 3.6b — Qualité correction — Frontend
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,15 @@
|
||||||
- Gating plan conforme PLANS_TARIFAIRES.md : revelation/diagnostic/conseil_nclc tous plans ; criteres/exercices/modele Standard+
|
- Gating plan conforme PLANS_TARIFAIRES.md : revelation/diagnostic/conseil_nclc tous plans ; criteres/exercices/modele Standard+
|
||||||
- Tests : 84 verts (+8 vs baseline — floutage + helpers lib + ExerciceInteractive)
|
- Tests : 84 verts (+8 vs baseline — floutage + helpers lib + ExerciceInteractive)
|
||||||
|
|
||||||
|
## Sprint 3.7 — Historique ✅
|
||||||
|
- Backend : `GET /simulations` — liste paginée des productions de l'utilisateur connecté (page/limit, tri `created_at DESC`, projection légère). 186 tests backend verts.
|
||||||
|
- Frontend : page `/historique` (route sous AppLayout), liste d'items (date relative, tâche, score /20, NCLC, badge Examen / En cours), pagination Précédent/Suivant, clic → `/rapport/:id`.
|
||||||
|
- Gating plan : Free → aperçu flouté + CTA « Passer en Standard » (`hasAccess(plan, 'dashboard')`) ; Standard + Premium → liste complète.
|
||||||
|
- État vide : CTA « Démarrer une simulation ».
|
||||||
|
- Hook `useSimulationsList(page, limit)` — TanStack Query, `staleTime: 30s`, `keepPreviousData` pour transitions fluides.
|
||||||
|
- Helper `formatRelativeDate` (Intl.RelativeTimeFormat, zéro dépendance).
|
||||||
|
- 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 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 : exercices long terme générés par DeepSeek sur patterns confirmés (≥ 3/5)
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { DashboardPage } from '@/features/dashboard/pages/DashboardPage'
|
||||||
import { SimulationPage } from '@/features/simulations/pages/SimulationPage'
|
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 { SimulationFlowProvider } from '@/features/simulations/state/SimulationFlowProvider'
|
import { SimulationFlowProvider } from '@/features/simulations/state/SimulationFlowProvider'
|
||||||
import { AppLayout } from './AppLayout'
|
import { AppLayout } from './AppLayout'
|
||||||
|
|
||||||
|
|
@ -71,7 +72,7 @@ export function AppRouter() {
|
||||||
<Route path="/examen" element={<ComingSoon />} />
|
<Route path="/examen" element={<ComingSoon />} />
|
||||||
<Route path="/progression" element={<ComingSoon />} />
|
<Route path="/progression" element={<ComingSoon />} />
|
||||||
<Route path="/methodologie" element={<ComingSoon />} />
|
<Route path="/methodologie" element={<ComingSoon />} />
|
||||||
<Route path="/historique" element={<ComingSoon />} />
|
<Route path="/historique" element={<HistoriquePage />} />
|
||||||
<Route path="/plan" element={<ComingSoon />} />
|
<Route path="/plan" element={<ComingSoon />} />
|
||||||
<Route path="/parametres" element={<ComingSoon />} />
|
<Route path="/parametres" element={<ComingSoon />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import type {
|
||||||
CreateSimulationPayload,
|
CreateSimulationPayload,
|
||||||
Production,
|
Production,
|
||||||
SimulationState,
|
SimulationState,
|
||||||
|
SimulationsListResponse,
|
||||||
SujetData,
|
SujetData,
|
||||||
Tache,
|
Tache,
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
@ -27,6 +28,19 @@ export function getSimulation(id: string): Promise<Production> {
|
||||||
return apiFetch<Production>(`/simulations/${id}`)
|
return apiFetch<Production>(`/simulations/${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sprint 3.7 — liste paginée des simulations de l'utilisateur connecté.
|
||||||
|
* Endpoint : `GET /simulations?page=X&limit=Y`. Tri `created_at DESC` côté backend.
|
||||||
|
* Champs lourds exclus (contenu, rapport, exercices, modele) — cf. SimulationListItem.
|
||||||
|
*/
|
||||||
|
export function listSimulations(
|
||||||
|
page: number,
|
||||||
|
limit: number,
|
||||||
|
): Promise<SimulationsListResponse> {
|
||||||
|
const qs = new URLSearchParams({ page: String(page), limit: String(limit) })
|
||||||
|
return apiFetch<SimulationsListResponse>(`/simulations?${qs.toString()}`)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* FTD-21 — récupère l'état complet d'une simulation (contenu + sujet + rapport).
|
* FTD-21 — récupère l'état complet d'une simulation (contenu + sujet + rapport).
|
||||||
* Utilisé par `SimulationFlowProvider` pour restaurer une session depuis
|
* Utilisé par `SimulationFlowProvider` pour restaurer une session depuis
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,30 @@ export interface SimulationState {
|
||||||
|
|
||||||
export type SimulationJobStatus = 'pending' | 'ready' | 'error'
|
export type SimulationJobStatus = 'pending' | 'ready' | 'error'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sprint 3.7 — Item léger pour la liste /historique.
|
||||||
|
* Miroir de `ListItem` côté backend (GET /simulations — pagination).
|
||||||
|
* Les champs lourds (contenu, rapport, exercices, modele) sont **exclus**.
|
||||||
|
*/
|
||||||
|
export interface SimulationListItem {
|
||||||
|
id: string
|
||||||
|
tache: Tache
|
||||||
|
mode: Mode
|
||||||
|
score: number | null
|
||||||
|
nclc: number | null
|
||||||
|
nclc_cible: 9 | 10 | null
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SimulationsListResponse {
|
||||||
|
data: SimulationListItem[]
|
||||||
|
pagination: {
|
||||||
|
page: number
|
||||||
|
limit: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rapport tel que stocké par le backend (sans `simulation_id`, `exercices`, `modele`
|
* Rapport tel que stocké par le backend (sans `simulation_id`, `exercices`, `modele`
|
||||||
* qui sont portés par SimulationState). Miroir de `CorrectionRapport` côté backend
|
* qui sont portés par SimulationState). Miroir de `CorrectionRapport` côté backend
|
||||||
|
|
|
||||||
59
src/features/historique/components/SimulationListItem.tsx
Normal file
59
src/features/historique/components/SimulationListItem.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
/**
|
||||||
|
* SimulationListItem — Sprint 3.7.
|
||||||
|
*
|
||||||
|
* Carte item de la page /historique. Clic → /rapport/:id (RapportPage gère le
|
||||||
|
* cas `rapport === null` en redirigeant vers /simulation/ee — FTD-21).
|
||||||
|
*
|
||||||
|
* Règle L : tokens Direction H exclusivement.
|
||||||
|
* Règle H : purement présentationnel — aucune logique plan ici.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { Badge } from '@/shared/ui/Badge'
|
||||||
|
import { formatTache } from '@/entities/production/lib'
|
||||||
|
import { formatRelativeDate } from '@/shared/lib/date'
|
||||||
|
import type { SimulationListItem as Item } from '@/entities/production/types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
item: Item
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SimulationListItem({ item }: Props) {
|
||||||
|
const hasScore = item.score !== null && item.nclc !== null
|
||||||
|
const isExam = item.mode === 'examen'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={`/rapport/${item.id}`}
|
||||||
|
className="block rounded-lg border border-line bg-surface p-4 shadow-sm transition-colors duration-150 hover:border-expria hover:bg-surface-hover focus-visible:outline-none focus-visible:shadow-focus"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0 space-y-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-sm font-semibold text-ink-1">{formatTache(item.tache)}</span>
|
||||||
|
{isExam && <Badge variant="nclc">Examen</Badge>}
|
||||||
|
{!hasScore && <Badge variant="neutral">En cours</Badge>}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-ink-4">{formatRelativeDate(item.created_at)}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasScore ? (
|
||||||
|
<div className="shrink-0 text-right">
|
||||||
|
<p className="tabular-nums text-ink-1">
|
||||||
|
<span className="text-xl font-bold">{item.score}</span>
|
||||||
|
<span className="text-sm font-medium text-ink-4">/20</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-ink-4 tabular-nums">
|
||||||
|
NCLC {item.nclc}
|
||||||
|
{item.nclc_cible ? ` / cible ${item.nclc_cible}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="shrink-0 text-right text-xs text-ink-4">
|
||||||
|
Score à venir
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
147
src/features/historique/components/SimulationsList.tsx
Normal file
147
src/features/historique/components/SimulationsList.tsx
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
/**
|
||||||
|
* SimulationsList — Sprint 3.7.
|
||||||
|
*
|
||||||
|
* Orchestre :
|
||||||
|
* - Loading / error / empty state
|
||||||
|
* - Gating plan : Free → aperçu flouté + upgrade ; Standard+ → liste
|
||||||
|
* - Pagination Précédent/Suivant (MVP)
|
||||||
|
*
|
||||||
|
* Règle D : passe par `hasAccess(plan, 'dashboard')` — jamais de `plan === 'free'`.
|
||||||
|
* Règle L : tokens Direction H exclusivement.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Lock } from 'lucide-react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { Card } from '@/shared/ui/Card'
|
||||||
|
import { Button } from '@/shared/ui/Button'
|
||||||
|
import { hasAccess, type Plan } from '@/entities/user/lib'
|
||||||
|
import type { SimulationsListResponse } from '@/entities/production/types'
|
||||||
|
import { SimulationListItem } from './SimulationListItem'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
plan: Plan
|
||||||
|
data: SimulationsListResponse | undefined
|
||||||
|
isLoading: boolean
|
||||||
|
isError: boolean
|
||||||
|
page: number
|
||||||
|
limit: number
|
||||||
|
onPrev: () => void
|
||||||
|
onNext: () => void
|
||||||
|
onUpgrade: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// Floutage local (dupliqué du pattern RapportPage — à extraire en shared/ si répété ailleurs).
|
||||||
|
const PLACEHOLDER_WIDTHS = ['w-3/4', 'w-full', 'w-3/5', 'w-4/5'] as const
|
||||||
|
|
||||||
|
function BlurredPreview({ onUpgrade }: { onUpgrade: () => void }) {
|
||||||
|
return (
|
||||||
|
<div className="relative min-h-[240px] 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_WIDTHS.map((w, i) => (
|
||||||
|
<div key={i} className={`h-16 rounded bg-ink-4 ${w}`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 px-4">
|
||||||
|
<Lock className="size-5 text-ink-4" aria-hidden="true" />
|
||||||
|
<p className="text-sm font-medium text-ink-2">Historique disponible en Standard</p>
|
||||||
|
<Button variant="upgrade" size="sm" onClick={onUpgrade}>
|
||||||
|
Passer en Standard
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ListSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3" aria-busy="true" aria-label="Chargement de l'historique…">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<div key={i} className="h-20 animate-pulse rounded-lg bg-canvas-2" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyState() {
|
||||||
|
return (
|
||||||
|
<Card variant="default" className="space-y-3 p-6 text-center">
|
||||||
|
<p className="text-sm text-ink-2">Aucune simulation pour le moment.</p>
|
||||||
|
<p className="text-xs text-ink-4">
|
||||||
|
Lancez votre première simulation pour commencer à construire votre historique.
|
||||||
|
</p>
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErrorState() {
|
||||||
|
return (
|
||||||
|
<Card variant="default" className="border-l-4 border-l-danger p-4">
|
||||||
|
<p className="text-sm text-danger" role="alert">
|
||||||
|
Impossible de charger l'historique. Réessayez dans quelques instants.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SimulationsList({
|
||||||
|
plan,
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
onPrev,
|
||||||
|
onNext,
|
||||||
|
onUpgrade,
|
||||||
|
}: Props) {
|
||||||
|
// Gating plan — Free voit uniquement l'aperçu flouté (Règle D).
|
||||||
|
if (!hasAccess(plan, 'dashboard')) {
|
||||||
|
return <BlurredPreview onUpgrade={onUpgrade} />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) return <ErrorState />
|
||||||
|
if (isLoading && !data) return <ListSkeleton />
|
||||||
|
if (!data) return null
|
||||||
|
|
||||||
|
if (data.data.length === 0) return <EmptyState />
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(data.pagination.total / limit))
|
||||||
|
const isFirst = page <= 1
|
||||||
|
const isLast = page >= totalPages
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{data.data.map((item) => (
|
||||||
|
<li key={item.id}>
|
||||||
|
<SimulationListItem item={item} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<nav
|
||||||
|
aria-label="Pagination de l'historique"
|
||||||
|
className="flex items-center justify-between gap-3 pt-2"
|
||||||
|
>
|
||||||
|
<Button variant="secondary" size="sm" onClick={onPrev} disabled={isFirst}>
|
||||||
|
Précédent
|
||||||
|
</Button>
|
||||||
|
<p className="text-xs text-ink-4 tabular-nums" aria-live="polite">
|
||||||
|
Page {page} sur {totalPages} — {data.pagination.total} simulations
|
||||||
|
</p>
|
||||||
|
<Button variant="secondary" size="sm" onClick={onNext} disabled={isLast}>
|
||||||
|
Suivant
|
||||||
|
</Button>
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,310 @@
|
||||||
|
/**
|
||||||
|
* Tests — SimulationsList (Sprint 3.7).
|
||||||
|
*
|
||||||
|
* Couvre :
|
||||||
|
* - État vide (Standard+)
|
||||||
|
* - Items rendus (Standard+)
|
||||||
|
* - Gating Free : aperçu flouté + bouton upgrade
|
||||||
|
* - Pagination : Précédent désactivé sur page 1, Suivant désactivé sur dernière page
|
||||||
|
* - Clic upgrade → onUpgrade appelé
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||||
|
import { render, screen, cleanup } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
|
import { SimulationsList } from '../SimulationsList'
|
||||||
|
import type { SimulationsListResponse } from '@/entities/production/types'
|
||||||
|
|
||||||
|
afterEach(cleanup)
|
||||||
|
|
||||||
|
function renderWithRouter(ui: React.ReactNode) {
|
||||||
|
return render(<MemoryRouter>{ui}</MemoryRouter>)
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY: SimulationsListResponse = {
|
||||||
|
data: [],
|
||||||
|
pagination: { page: 1, limit: 20, total: 0 },
|
||||||
|
}
|
||||||
|
|
||||||
|
const THREE_ITEMS: SimulationsListResponse = {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: 'p1',
|
||||||
|
tache: 'EE_T1',
|
||||||
|
mode: 'entrainement',
|
||||||
|
score: 14,
|
||||||
|
nclc: 9,
|
||||||
|
nclc_cible: 9,
|
||||||
|
created_at: '2026-04-22T10:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'p2',
|
||||||
|
tache: 'EE_T2',
|
||||||
|
mode: 'examen',
|
||||||
|
score: 16,
|
||||||
|
nclc: 10,
|
||||||
|
nclc_cible: 10,
|
||||||
|
created_at: '2026-04-22T09:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'p3',
|
||||||
|
tache: 'EE_T3',
|
||||||
|
mode: 'entrainement',
|
||||||
|
score: null,
|
||||||
|
nclc: null,
|
||||||
|
nclc_cible: null,
|
||||||
|
created_at: '2026-04-22T08:00:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pagination: { page: 1, limit: 20, total: 3 },
|
||||||
|
}
|
||||||
|
|
||||||
|
const NOOP = () => {}
|
||||||
|
|
||||||
|
describe('SimulationsList — plan Free (gating)', () => {
|
||||||
|
it('affiche l\'aperçu flouté et le bouton "Passer en Standard"', () => {
|
||||||
|
renderWithRouter(
|
||||||
|
<SimulationsList
|
||||||
|
plan="free"
|
||||||
|
data={THREE_ITEMS}
|
||||||
|
isLoading={false}
|
||||||
|
isError={false}
|
||||||
|
page={1}
|
||||||
|
limit={20}
|
||||||
|
onPrev={NOOP}
|
||||||
|
onNext={NOOP}
|
||||||
|
onUpgrade={NOOP}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByText(/historique disponible en standard/i)).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: /passer en standard/i })).toBeInTheDocument()
|
||||||
|
// Les items NE doivent PAS être accessibles à Free
|
||||||
|
expect(screen.queryByText('Expression Écrite — Tâche 1')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clic sur "Passer en Standard" appelle onUpgrade', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const onUpgrade = vi.fn()
|
||||||
|
renderWithRouter(
|
||||||
|
<SimulationsList
|
||||||
|
plan="free"
|
||||||
|
data={THREE_ITEMS}
|
||||||
|
isLoading={false}
|
||||||
|
isError={false}
|
||||||
|
page={1}
|
||||||
|
limit={20}
|
||||||
|
onPrev={NOOP}
|
||||||
|
onNext={NOOP}
|
||||||
|
onUpgrade={onUpgrade}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /passer en standard/i }))
|
||||||
|
expect(onUpgrade).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('SimulationsList — plan Standard', () => {
|
||||||
|
it('affiche l\'état vide avec le CTA "Démarrer une simulation"', () => {
|
||||||
|
renderWithRouter(
|
||||||
|
<SimulationsList
|
||||||
|
plan="standard"
|
||||||
|
data={EMPTY}
|
||||||
|
isLoading={false}
|
||||||
|
isError={false}
|
||||||
|
page={1}
|
||||||
|
limit={20}
|
||||||
|
onPrev={NOOP}
|
||||||
|
onNext={NOOP}
|
||||||
|
onUpgrade={NOOP}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByText(/aucune simulation/i)).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('link', { name: /démarrer une simulation/i })).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
'/simulation/ee',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rend les items avec score, NCLC et badge "Examen" quand mode=examen', () => {
|
||||||
|
renderWithRouter(
|
||||||
|
<SimulationsList
|
||||||
|
plan="standard"
|
||||||
|
data={THREE_ITEMS}
|
||||||
|
isLoading={false}
|
||||||
|
isError={false}
|
||||||
|
page={1}
|
||||||
|
limit={20}
|
||||||
|
onPrev={NOOP}
|
||||||
|
onNext={NOOP}
|
||||||
|
onUpgrade={NOOP}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// 3 items rendus
|
||||||
|
const links = screen.getAllByRole('link')
|
||||||
|
expect(links).toHaveLength(3)
|
||||||
|
// Item p1 : score 14, NCLC 9 / cible 9
|
||||||
|
expect(screen.getByText('14')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/NCLC 9 \/ cible 9/)).toBeInTheDocument()
|
||||||
|
// Item p2 (examen) : badge Examen
|
||||||
|
expect(screen.getByText('Examen')).toBeInTheDocument()
|
||||||
|
// Item p3 (score null) : badge "En cours"
|
||||||
|
expect(screen.getByText('En cours')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('chaque item pointe vers /rapport/:id', () => {
|
||||||
|
renderWithRouter(
|
||||||
|
<SimulationsList
|
||||||
|
plan="standard"
|
||||||
|
data={THREE_ITEMS}
|
||||||
|
isLoading={false}
|
||||||
|
isError={false}
|
||||||
|
page={1}
|
||||||
|
limit={20}
|
||||||
|
onPrev={NOOP}
|
||||||
|
onNext={NOOP}
|
||||||
|
onUpgrade={NOOP}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByRole('link', { name: /expression écrite.*tâche 1/i })).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
'/rapport/p1',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('SimulationsList — pagination', () => {
|
||||||
|
it('un seul page (total ≤ limit) : pas de nav de pagination', () => {
|
||||||
|
renderWithRouter(
|
||||||
|
<SimulationsList
|
||||||
|
plan="standard"
|
||||||
|
data={THREE_ITEMS}
|
||||||
|
isLoading={false}
|
||||||
|
isError={false}
|
||||||
|
page={1}
|
||||||
|
limit={20}
|
||||||
|
onPrev={NOOP}
|
||||||
|
onNext={NOOP}
|
||||||
|
onUpgrade={NOOP}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.queryByRole('navigation', { name: /pagination/i })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('page 1 sur plusieurs : Précédent désactivé, Suivant actif', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const onNext = vi.fn()
|
||||||
|
const multi: SimulationsListResponse = {
|
||||||
|
data: THREE_ITEMS.data,
|
||||||
|
pagination: { page: 1, limit: 20, total: 50 },
|
||||||
|
}
|
||||||
|
renderWithRouter(
|
||||||
|
<SimulationsList
|
||||||
|
plan="standard"
|
||||||
|
data={multi}
|
||||||
|
isLoading={false}
|
||||||
|
isError={false}
|
||||||
|
page={1}
|
||||||
|
limit={20}
|
||||||
|
onPrev={NOOP}
|
||||||
|
onNext={onNext}
|
||||||
|
onUpgrade={NOOP}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: /précédent/i })).toBeDisabled()
|
||||||
|
const suivant = screen.getByRole('button', { name: /suivant/i })
|
||||||
|
expect(suivant).toBeEnabled()
|
||||||
|
await user.click(suivant)
|
||||||
|
expect(onNext).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('dernière page : Suivant désactivé', () => {
|
||||||
|
const last: SimulationsListResponse = {
|
||||||
|
data: THREE_ITEMS.data,
|
||||||
|
pagination: { page: 3, limit: 20, total: 50 }, // 50 / 20 = 3 pages
|
||||||
|
}
|
||||||
|
renderWithRouter(
|
||||||
|
<SimulationsList
|
||||||
|
plan="standard"
|
||||||
|
data={last}
|
||||||
|
isLoading={false}
|
||||||
|
isError={false}
|
||||||
|
page={3}
|
||||||
|
limit={20}
|
||||||
|
onPrev={NOOP}
|
||||||
|
onNext={NOOP}
|
||||||
|
onUpgrade={NOOP}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: /suivant/i })).toBeDisabled()
|
||||||
|
expect(screen.getByRole('button', { name: /précédent/i })).toBeEnabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('affiche le compteur "Page X sur Y — Z simulations"', () => {
|
||||||
|
const multi: SimulationsListResponse = {
|
||||||
|
data: THREE_ITEMS.data,
|
||||||
|
pagination: { page: 2, limit: 20, total: 50 },
|
||||||
|
}
|
||||||
|
renderWithRouter(
|
||||||
|
<SimulationsList
|
||||||
|
plan="standard"
|
||||||
|
data={multi}
|
||||||
|
isLoading={false}
|
||||||
|
isError={false}
|
||||||
|
page={2}
|
||||||
|
limit={20}
|
||||||
|
onPrev={NOOP}
|
||||||
|
onNext={NOOP}
|
||||||
|
onUpgrade={NOOP}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByText(/Page 2 sur 3 — 50 simulations/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('SimulationsList — états transverses', () => {
|
||||||
|
it('isError → affiche le callout d\'erreur', () => {
|
||||||
|
renderWithRouter(
|
||||||
|
<SimulationsList
|
||||||
|
plan="standard"
|
||||||
|
data={undefined}
|
||||||
|
isLoading={false}
|
||||||
|
isError={true}
|
||||||
|
page={1}
|
||||||
|
limit={20}
|
||||||
|
onPrev={NOOP}
|
||||||
|
onNext={NOOP}
|
||||||
|
onUpgrade={NOOP}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByRole('alert')).toHaveTextContent(/impossible de charger/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('isLoading + pas de data → squelettes', () => {
|
||||||
|
renderWithRouter(
|
||||||
|
<SimulationsList
|
||||||
|
plan="standard"
|
||||||
|
data={undefined}
|
||||||
|
isLoading={true}
|
||||||
|
isError={false}
|
||||||
|
page={1}
|
||||||
|
limit={20}
|
||||||
|
onPrev={NOOP}
|
||||||
|
onNext={NOOP}
|
||||||
|
onUpgrade={NOOP}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByLabelText(/chargement de l'historique/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
27
src/features/historique/hooks/useSimulationsList.ts
Normal file
27
src/features/historique/hooks/useSimulationsList.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
/**
|
||||||
|
* Hook TanStack Query — liste paginée des simulations.
|
||||||
|
*
|
||||||
|
* Clé de cache : `['simulations', 'list', page, limit]`. `staleTime: 30 s` —
|
||||||
|
* l'historique change peu entre deux requêtes utilisateur, 30 s évite les
|
||||||
|
* rafraîchissements inutiles tout en gardant les données fraîches.
|
||||||
|
*
|
||||||
|
* `placeholderData: keepPreviousData` (TanStack v5) permet un changement de
|
||||||
|
* page sans flash de squelette — les items précédents restent affichés
|
||||||
|
* pendant le fetch.
|
||||||
|
*
|
||||||
|
* Règle H : aucune logique métier ici — le hook ne fait qu'envelopper l'API.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { keepPreviousData, useQuery } from '@tanstack/react-query'
|
||||||
|
import { listSimulations } from '@/entities/production/api'
|
||||||
|
|
||||||
|
const DEFAULT_LIMIT = 20
|
||||||
|
|
||||||
|
export function useSimulationsList(page: number, limit: number = DEFAULT_LIMIT) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['simulations', 'list', page, limit] as const,
|
||||||
|
queryFn: () => listSimulations(page, limit),
|
||||||
|
staleTime: 30 * 1000,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
})
|
||||||
|
}
|
||||||
58
src/features/historique/pages/HistoriquePage.tsx
Normal file
58
src/features/historique/pages/HistoriquePage.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
/**
|
||||||
|
* Page /historique — liste paginée des simulations de l'utilisateur connecté.
|
||||||
|
*
|
||||||
|
* Consommateurs amont :
|
||||||
|
* - `usePlan` (cache partagé avec dashboard/simulation)
|
||||||
|
* - `useSimulationsList(page, limit)` — cache `['simulations', 'list', p, l]`
|
||||||
|
*
|
||||||
|
* Gating Free via `hasAccess(plan, 'dashboard')` délégué à `SimulationsList`.
|
||||||
|
* Pagination : Précédent/Suivant via state local `page`.
|
||||||
|
*
|
||||||
|
* Règle H : aucune logique métier — toute l'orchestration vit dans SimulationsList.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { usePlan } from '@/features/dashboard/hooks/usePlan'
|
||||||
|
import { useSimulationsList } from '../hooks/useSimulationsList'
|
||||||
|
import { SimulationsList } from '../components/SimulationsList'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
|
export function HistoriquePage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
|
||||||
|
const { data: planData, isLoading: isPlanLoading } = usePlan()
|
||||||
|
const { data, isLoading, isError } = useSimulationsList(page, PAGE_SIZE)
|
||||||
|
|
||||||
|
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">Historique</h1>
|
||||||
|
<p className="text-sm text-ink-3">
|
||||||
|
Retrouvez toutes vos simulations passées et leur progression.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{isPlanLoading || !planData ? (
|
||||||
|
<div className="space-y-3" aria-busy="true">
|
||||||
|
<div className="h-20 animate-pulse rounded-lg bg-canvas-2" />
|
||||||
|
<div className="h-20 animate-pulse rounded-lg bg-canvas-2" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<SimulationsList
|
||||||
|
plan={planData.plan}
|
||||||
|
data={data}
|
||||||
|
isLoading={isLoading}
|
||||||
|
isError={isError}
|
||||||
|
page={page}
|
||||||
|
limit={PAGE_SIZE}
|
||||||
|
onPrev={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
onNext={() => setPage((p) => p + 1)}
|
||||||
|
onUpgrade={() => navigate('/plan')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
43
src/shared/lib/__tests__/date.test.ts
Normal file
43
src/shared/lib/__tests__/date.test.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { formatRelativeDate } from '../date'
|
||||||
|
|
||||||
|
const NOW = new Date('2026-04-22T12:00:00Z')
|
||||||
|
|
||||||
|
describe('formatRelativeDate', () => {
|
||||||
|
it('il y a quelques secondes', () => {
|
||||||
|
const d = new Date(NOW.getTime() - 10 * 1000).toISOString()
|
||||||
|
expect(formatRelativeDate(d, NOW)).toMatch(/seconde/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('il y a 5 minutes', () => {
|
||||||
|
const d = new Date(NOW.getTime() - 5 * 60 * 1000).toISOString()
|
||||||
|
expect(formatRelativeDate(d, NOW)).toContain('5')
|
||||||
|
expect(formatRelativeDate(d, NOW)).toMatch(/minute/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('il y a 3 heures', () => {
|
||||||
|
const d = new Date(NOW.getTime() - 3 * 60 * 60 * 1000).toISOString()
|
||||||
|
expect(formatRelativeDate(d, NOW)).toContain('3')
|
||||||
|
expect(formatRelativeDate(d, NOW)).toMatch(/heure/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('avant-hier (numeric: auto)', () => {
|
||||||
|
const d = new Date(NOW.getTime() - 2 * 24 * 60 * 60 * 1000).toISOString()
|
||||||
|
expect(formatRelativeDate(d, NOW)).toBe('avant-hier')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('il y a 4 jours', () => {
|
||||||
|
const d = new Date(NOW.getTime() - 4 * 24 * 60 * 60 * 1000).toISOString()
|
||||||
|
expect(formatRelativeDate(d, NOW)).toContain('4')
|
||||||
|
expect(formatRelativeDate(d, NOW)).toMatch(/jour/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('la semaine dernière (numeric: auto)', () => {
|
||||||
|
const d = new Date(NOW.getTime() - 8 * 24 * 60 * 60 * 1000).toISOString()
|
||||||
|
expect(formatRelativeDate(d, NOW)).toMatch(/semaine/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retourne une chaîne vide sur ISO invalide', () => {
|
||||||
|
expect(formatRelativeDate('not-a-date', NOW)).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
32
src/shared/lib/date.ts
Normal file
32
src/shared/lib/date.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
/**
|
||||||
|
* Helper de formatage de dates relatives — zéro dépendance (Intl.RelativeTimeFormat).
|
||||||
|
*
|
||||||
|
* Exemple : `formatRelativeDate('2026-04-22T10:00:00Z', now)` → « il y a 2 jours »
|
||||||
|
*
|
||||||
|
* Seuils : secondes → minutes → heures → jours → semaines → mois → années.
|
||||||
|
* Locale fixée à `'fr'` — Expria est monolingue français (cf. DESIGN_SYSTEM.md §10).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const RTF = new Intl.RelativeTimeFormat('fr', { numeric: 'auto' })
|
||||||
|
|
||||||
|
const MINUTE = 60
|
||||||
|
const HOUR = 60 * MINUTE
|
||||||
|
const DAY = 24 * HOUR
|
||||||
|
const WEEK = 7 * DAY
|
||||||
|
const MONTH = 30 * DAY
|
||||||
|
const YEAR = 365 * DAY
|
||||||
|
|
||||||
|
export function formatRelativeDate(iso: string, now: Date = new Date()): string {
|
||||||
|
const then = new Date(iso).getTime()
|
||||||
|
if (!Number.isFinite(then)) return ''
|
||||||
|
const diffSec = Math.round((then - now.getTime()) / 1000)
|
||||||
|
const abs = Math.abs(diffSec)
|
||||||
|
|
||||||
|
if (abs < MINUTE) return RTF.format(Math.round(diffSec / 1), 'second')
|
||||||
|
if (abs < HOUR) return RTF.format(Math.round(diffSec / MINUTE), 'minute')
|
||||||
|
if (abs < DAY) return RTF.format(Math.round(diffSec / HOUR), 'hour')
|
||||||
|
if (abs < WEEK) return RTF.format(Math.round(diffSec / DAY), 'day')
|
||||||
|
if (abs < MONTH) return RTF.format(Math.round(diffSec / WEEK), 'week')
|
||||||
|
if (abs < YEAR) return RTF.format(Math.round(diffSec / MONTH), 'month')
|
||||||
|
return RTF.format(Math.round(diffSec / YEAR), 'year')
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue