feat(historique): refonte pixel-perfect avec stats + filtres + tendance 30j (Sprint 4.7)

Inclut le retrait du padding de AppLayout et le wrapper standardisé
(mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9) ajouté sur
11 pages (Dashboard, Progression, 9 pages Simulation EE/EO/T1) pour
laisser chaque page gérer son max-width.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hermann_Kitio 2026-04-26 00:04:12 +03:00
parent d8bae9520c
commit 3ce91aaa7b
20 changed files with 1417 additions and 874 deletions

View file

@ -77,7 +77,9 @@ export function AppLayout({ children }: AppLayoutProps) {
style={{ background: mainBackground }} style={{ background: mainBackground }}
> >
<Topbar onMobileMenuOpen={() => setIsMobileMenuOpen(true)} /> <Topbar onMobileMenuOpen={() => setIsMobileMenuOpen(true)} />
<div className="mx-auto max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">{children}</div> {/* Pas de padding ni de max-width ici : chaque page gère sa propre
largeur de contenu et son propre padding (cf. HistoriquePage). */}
{children}
</main> </main>
{/* ── MOBILE — BottomNav fixe ────────────────────────────────── */} {/* ── MOBILE — BottomNav fixe ────────────────────────────────── */}

View file

@ -39,7 +39,7 @@ function DashboardSkeleton() {
) )
} }
export function DashboardPage() { function DashboardContent() {
const { user } = useAuth() const { user } = useAuth()
const { data, isLoading, isError } = usePlan() const { data, isLoading, isError } = usePlan()
const queryClient = useQueryClient() const queryClient = useQueryClient()
@ -88,3 +88,11 @@ export function DashboardPage() {
return <DashboardStandardView displayName={displayName} simulationsUsed={data.simulations_used} /> return <DashboardStandardView displayName={displayName} simulationsUsed={data.simulations_used} />
} }
export function DashboardPage() {
return (
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
<DashboardContent />
</div>
)
}

View file

@ -0,0 +1,189 @@
import { describe, it, expect } from 'vitest'
import {
applyFilters,
computeStats,
computeTrend,
formatShortDate,
formatTaskLabel,
nclcChipVariant,
} from '../lib/historique'
import type { SimulationListItem } from '@/entities/production/types'
const NOW = new Date('2026-04-25T12:00:00Z')
function item(
overrides: Partial<SimulationListItem> & { id: string; created_at: string },
): SimulationListItem {
return {
id: overrides.id,
tache: overrides.tache ?? 'EE_T1',
mode: overrides.mode ?? 'entrainement',
score: overrides.score ?? null,
nclc: overrides.nclc ?? null,
nclc_cible: overrides.nclc_cible ?? null,
created_at: overrides.created_at,
}
}
describe('applyFilters', () => {
const items: SimulationListItem[] = [
item({ id: 'a', tache: 'EE_T1', created_at: '2026-04-22T10:00:00Z', score: 14 }),
item({ id: 'b', tache: 'EE_T2', created_at: '2026-04-10T10:00:00Z', score: 12 }),
item({ id: 'c', tache: 'EO_T1', created_at: '2026-02-15T10:00:00Z', score: 16 }),
item({ id: 'd', tache: 'EO_T3', created_at: '2025-12-01T10:00:00Z', score: 10 }),
]
it('task=all + period=all → tous les items', () => {
expect(applyFilters(items, { task: 'all', period: 'all' }, NOW).map((i) => i.id)).toEqual([
'a',
'b',
'c',
'd',
])
})
it('filtre par tâche', () => {
expect(applyFilters(items, { task: 'EE_T1', period: 'all' }, NOW).map((i) => i.id)).toEqual([
'a',
])
})
it("period=this-month garde uniquement les items d'avril 2026", () => {
expect(
applyFilters(items, { task: 'all', period: 'this-month' }, NOW).map((i) => i.id),
).toEqual(['a', 'b'])
})
it('period=3-months exclut les items > 90 jours', () => {
expect(applyFilters(items, { task: 'all', period: '3-months' }, NOW).map((i) => i.id)).toEqual([
'a',
'b',
'c',
])
})
it('combine tâche + période', () => {
expect(
applyFilters(items, { task: 'EE_T2', period: 'this-month' }, NOW).map((i) => i.id),
).toEqual(['b'])
})
})
describe('computeStats', () => {
it('dataset vide → all null', () => {
expect(computeStats([], NOW)).toEqual({
total: 0,
thisMonth: 0,
average: null,
best: null,
})
})
it('ignore les scores null pour average + best', () => {
const items = [
item({ id: 'a', created_at: '2026-04-20T10:00:00Z', score: 12 }),
item({ id: 'b', created_at: '2026-04-21T10:00:00Z', score: null }),
item({ id: 'c', created_at: '2026-04-22T10:00:00Z', score: 18 }),
]
const s = computeStats(items, NOW)
expect(s.total).toBe(3)
expect(s.thisMonth).toBe(3)
expect(s.average).toBe(15)
expect(s.best?.score).toBe(18)
expect(s.best?.created_at).toBe('2026-04-22T10:00:00Z')
})
it('thisMonth ne compte que le mois courant', () => {
const items = [
item({ id: 'a', created_at: '2026-04-22T10:00:00Z', score: 14 }),
item({ id: 'b', created_at: '2026-03-22T10:00:00Z', score: 14 }),
]
expect(computeStats(items, NOW).thisMonth).toBe(1)
})
})
describe('computeTrend', () => {
it('retourne null si fenêtre récente vide', () => {
const items = [item({ id: 'a', created_at: '2026-02-15T10:00:00Z', score: 10 })]
expect(computeTrend(items, NOW)).toBeNull()
})
it('retourne null si fenêtre précédente vide', () => {
const items = [item({ id: 'a', created_at: '2026-04-20T10:00:00Z', score: 14 })]
expect(computeTrend(items, NOW)).toBeNull()
})
it('détecte une tendance up', () => {
const items = [
// récents (030j) : moyenne 15
item({ id: 'a', created_at: '2026-04-20T10:00:00Z', score: 16 }),
item({ id: 'b', created_at: '2026-04-10T10:00:00Z', score: 14 }),
// précédents (3060j) : moyenne 12
item({ id: 'c', created_at: '2026-03-20T10:00:00Z', score: 12 }),
item({ id: 'd', created_at: '2026-03-10T10:00:00Z', score: 12 }),
]
const t = computeTrend(items, NOW)
expect(t?.direction).toBe('up')
expect(t?.delta).toBeCloseTo(3, 5)
})
it('détecte une tendance down', () => {
const items = [
item({ id: 'a', created_at: '2026-04-20T10:00:00Z', score: 10 }),
item({ id: 'b', created_at: '2026-03-15T10:00:00Z', score: 14 }),
]
const t = computeTrend(items, NOW)
expect(t?.direction).toBe('down')
expect(t?.delta).toBeCloseTo(4, 5)
})
it('ignore les scores null', () => {
const items = [
item({ id: 'a', created_at: '2026-04-20T10:00:00Z', score: null }),
item({ id: 'b', created_at: '2026-04-10T10:00:00Z', score: 14 }),
item({ id: 'c', created_at: '2026-03-15T10:00:00Z', score: 12 }),
]
const t = computeTrend(items, NOW)
expect(t?.direction).toBe('up')
expect(t?.delta).toBeCloseTo(2, 5)
})
})
describe('formatShortDate', () => {
it('formate "22 avr." en fr-FR', () => {
expect(formatShortDate('2026-04-22T10:00:00Z')).toMatch(/22 avr/)
})
it('retourne chaîne vide pour ISO invalide', () => {
expect(formatShortDate('not-a-date')).toBe('')
})
})
describe('formatTaskLabel', () => {
it('entraînement → "EE · Tâche 3"', () => {
expect(formatTaskLabel({ tache: 'EE_T3', mode: 'entrainement' })).toBe('EE · Tâche 3')
})
it('examen EE → "Examen blanc EE"', () => {
expect(formatTaskLabel({ tache: 'EE_T1', mode: 'examen' })).toBe('Examen blanc EE')
})
it('examen EO → "Examen blanc EO"', () => {
expect(formatTaskLabel({ tache: 'EO_T3', mode: 'examen' })).toBe('Examen blanc EO')
})
})
describe('nclcChipVariant', () => {
it('≥ 9 → ok', () => {
expect(nclcChipVariant(9)).toBe('ok')
expect(nclcChipVariant(12)).toBe('ok')
})
it('7-8 → warn', () => {
expect(nclcChipVariant(7)).toBe('warn')
expect(nclcChipVariant(8)).toBe('warn')
})
it('≤ 6 → err', () => {
expect(nclcChipVariant(6)).toBe('err')
expect(nclcChipVariant(0)).toBe('err')
})
})

View file

@ -0,0 +1,129 @@
/**
* Filtres de la page /historique refonte Sprint 4.7 + correction theming.
*
* Dropdowns custom (div + état ouvert/fermé) zéro lib externe pour
* garantir la lisibilité en dark/light. Tokens DA Charcoal (Règle L).
*
* Accessibilité minimale : button aria-haspopup, fermeture sur clic
* extérieur ou Escape, options atteignables au clic.
*/
import { useEffect, useRef, useState } from 'react'
import { ChevronDown } from 'lucide-react'
import type { PeriodFilter, TaskFilter } from '../lib/historique'
interface Props {
task: TaskFilter
period: PeriodFilter
onTaskChange: (task: TaskFilter) => void
onPeriodChange: (period: PeriodFilter) => void
}
const TASK_OPTIONS: { value: TaskFilter; label: string }[] = [
{ value: 'all', label: 'Toutes les tâches' },
{ value: 'EE_T1', label: 'EE T1' },
{ value: 'EE_T2', label: 'EE T2' },
{ value: 'EE_T3', label: 'EE T3' },
{ value: 'EO_T1', label: 'EO T1' },
{ value: 'EO_T3', label: 'EO T3' },
]
const PERIOD_OPTIONS: { value: PeriodFilter; label: string }[] = [
{ value: 'this-month', label: 'Ce mois' },
{ value: '3-months', label: '3 mois' },
{ value: 'all', label: 'Tout' },
]
interface DropdownProps<T extends string> {
value: T
options: { value: T; label: string }[]
onChange: (value: T) => void
ariaLabel: string
}
function Dropdown<T extends string>({ value, options, onChange, ariaLabel }: DropdownProps<T>) {
const [open, setOpen] = useState(false)
const rootRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
function onDocClick(e: MouseEvent) {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false)
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('mousedown', onDocClick)
document.addEventListener('keydown', onKey)
return () => {
document.removeEventListener('mousedown', onDocClick)
document.removeEventListener('keydown', onKey)
}
}, [open])
const selected = options.find((o) => o.value === value) ?? options[0]
return (
<div ref={rootRef} className="relative">
<button
type="button"
aria-haspopup="listbox"
aria-expanded={open}
aria-label={ariaLabel}
onClick={() => setOpen((o) => !o)}
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-surface px-[14px] py-[7px] text-[12.5px] font-semibold text-ink-primary hover:bg-surface-hover focus-visible:outline-none focus-visible:shadow-focus"
>
<span>{selected.label}</span>
<ChevronDown className="size-3.5 text-ink-tertiary" aria-hidden="true" />
</button>
{open && (
<ul
role="listbox"
className="absolute right-0 z-10 mt-1 min-w-full overflow-hidden rounded-lg border border-border bg-surface-solid shadow-card"
>
{options.map((opt) => {
const isActive = opt.value === value
return (
<li key={opt.value}>
<button
type="button"
role="option"
aria-selected={isActive}
onClick={() => {
onChange(opt.value)
setOpen(false)
}}
className={`block w-full whitespace-nowrap px-[14px] py-2 text-left text-[12.5px] font-medium hover:bg-surface-hover focus-visible:bg-surface-hover focus-visible:outline-none ${
isActive ? 'text-brand-text' : 'text-ink-primary'
}`}
>
{opt.label}
</button>
</li>
)
})}
</ul>
)}
</div>
)
}
export function HistoriqueFilters({ task, period, onTaskChange, onPeriodChange }: Props) {
return (
<div className="flex flex-wrap items-center gap-2">
<Dropdown
value={task}
options={TASK_OPTIONS}
onChange={onTaskChange}
ariaLabel="Filtrer par tâche"
/>
<Dropdown
value={period}
options={PERIOD_OPTIONS}
onChange={onPeriodChange}
ariaLabel="Filtrer par période"
/>
</div>
)
}

View file

@ -0,0 +1,118 @@
/**
* 3 cartes métriques en haut de /historique Sprint 4.7.
*
* Total simulations / Score moyen / Meilleur score. Recalculées à chaque
* changement de filtres (les filtres sont appliqués en amont par la page).
*
* Règle L : tokens DA Charcoal exclusivement.
* Règle H : purement présentationnel.
*/
import {
formatShortDate,
formatTaskLabel,
type HistoriqueStats,
type Trend,
} from '../lib/historique'
interface Props {
stats: HistoriqueStats
trend: Trend | null
}
const NUMBER_FR = new Intl.NumberFormat('fr-FR', {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
})
function Label({ children }: { children: React.ReactNode }) {
return (
<p className="text-[11.5px] font-medium uppercase tracking-[0.04em] text-ink-secondary">
{children}
</p>
)
}
function Value({ children }: { children: React.ReactNode }) {
return (
<span className="text-[34px] font-bold leading-none tracking-[-0.03em] tabular-nums text-ink-primary">
{children}
</span>
)
}
function Unit({ children }: { children: React.ReactNode }) {
return <span className="ml-[3px] text-[15px] font-medium text-ink-tertiary">{children}</span>
}
function Footer({ children }: { children: React.ReactNode }) {
return <p className="mt-2 text-[11.5px] text-ink-tertiary">{children}</p>
}
function TrendChip({ trend }: { trend: Trend }) {
const isUp = trend.direction === 'up'
const sign = isUp ? '+' : '-'
const label = `${sign}${NUMBER_FR.format(trend.delta)} en 30j`
const colorClasses = isUp
? 'bg-success-soft text-success border-success/30'
: 'bg-danger-soft text-danger border-danger/30'
return (
<span
className={`mt-2 inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[11.5px] font-semibold ${colorClasses}`}
>
<span aria-hidden="true">{isUp ? '↑' : '↓'}</span>
{label}
</span>
)
}
export function HistoriqueStatsCards({ stats, trend }: Props) {
return (
<div className="grid w-full grid-cols-3 gap-3">
<div className="rounded-[12px] border border-border bg-surface p-4 px-[18px] shadow-card">
<Label>Total simulations</Label>
<div className="mt-2 flex items-baseline">
<Value>{stats.total}</Value>
</div>
<Footer>dont {stats.thisMonth} ce mois</Footer>
</div>
<div className="rounded-[12px] border border-border bg-surface p-4 px-[18px] shadow-card">
<Label>Score moyen</Label>
<div className="mt-2 flex items-baseline">
{stats.average !== null ? (
<>
<Value>{NUMBER_FR.format(stats.average)}</Value>
<Unit>/20</Unit>
</>
) : (
<Value></Value>
)}
</div>
{trend ? <TrendChip trend={trend} /> : <Footer></Footer>}
</div>
<div className="rounded-[12px] border border-border bg-surface p-4 px-[18px] shadow-card">
<Label>Meilleur score</Label>
<div className="mt-2 flex items-baseline">
{stats.best !== null ? (
<>
<Value>{stats.best.score}</Value>
<Unit>/20</Unit>
</>
) : (
<Value></Value>
)}
</div>
{stats.best !== null ? (
<Footer>
{formatTaskLabel({ tache: stats.best.tache, mode: 'entrainement' })} ·{' '}
{formatShortDate(stats.best.created_at)}
</Footer>
) : (
<Footer></Footer>
)}
</div>
</div>
)
}

View file

@ -1,59 +1,65 @@
/** /**
* SimulationListItem Sprint 3.7. * Item d'une ligne de la liste /historique réécrit Sprint 4.7 selon maquette.
* *
* Carte item de la page /historique. Clic /rapport/:id (RapportPage gère le * Layout flex : Date · Libellé · Badge NCLC · Score · Chevron.
* cas `rapport === null` en redirigeant vers /simulation/ee FTD-21). * Couleur du badge NCLC selon seuil (cf. `nclcChipVariant`).
* *
* Règle L : tokens Direction H exclusivement. * Règle L : tokens DA Charcoal exclusivement.
* Règle H : purement présentationnel aucune logique plan ici. * Règle H : purement présentationnel.
*/ */
import { ChevronRight } from 'lucide-react'
import { Link } from 'react-router-dom' 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' import type { SimulationListItem as Item } from '@/entities/production/types'
import { formatShortDate, formatTaskLabel, nclcChipVariant } from '../lib/historique'
interface Props { interface Props {
item: Item item: Item
isLast: boolean
} }
export function SimulationListItem({ item }: Props) { const CHIP_BASE =
'inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide'
const CHIP_OK = 'bg-success-soft text-success border-success/30'
const CHIP_WARN = 'bg-warning-soft text-warning border-warning/30'
const CHIP_ERR = 'bg-danger-soft text-danger border-danger/30'
const CHIP_NEUTRAL = 'bg-surface text-ink-secondary border-border'
function NclcBadge({ nclc }: { nclc: number }) {
const variant = nclcChipVariant(nclc)
const cls = variant === 'ok' ? CHIP_OK : variant === 'warn' ? CHIP_WARN : CHIP_ERR
return <span className={`${CHIP_BASE} ${cls}`}>NCLC {nclc}</span>
}
export function SimulationListItem({ item, isLast }: Props) {
const hasScore = item.score !== null && item.nclc !== null const hasScore = item.score !== null && item.nclc !== null
const isExam = item.mode === 'examen' const borderClass = isLast ? '' : 'border-b border-border'
return ( return (
<Link <Link
to={`/rapport/${item.id}`} to={`/rapport/${item.id}`}
className="block rounded-lg border border-border bg-surface p-4 shadow-card transition-colors duration-150 hover:border-brand hover:bg-surface-hover focus-visible:outline-none focus-visible:shadow-focus" className={`flex items-center gap-[14px] px-4 py-[14px] transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:shadow-focus ${borderClass}`}
> >
<div className="flex items-start justify-between gap-3"> <span className="w-[68px] shrink-0 text-[11.5px] tabular-nums text-ink-tertiary">
<div className="min-w-0 space-y-1"> {formatShortDate(item.created_at)}
<div className="flex flex-wrap items-center gap-2"> </span>
<span className="text-sm font-semibold text-ink-primary">
{formatTache(item.tache)}
</span>
{isExam && <Badge variant="nclc">Examen</Badge>}
{!hasScore && <Badge variant="neutral">En cours</Badge>}
</div>
<p className="text-xs text-ink-secondary">{formatRelativeDate(item.created_at)}</p>
</div>
{hasScore ? ( <span className="min-w-0 flex-1 truncate text-[13px] font-medium text-ink-primary">
<div className="shrink-0 text-right"> {formatTaskLabel(item)}
<p className="tabular-nums text-ink-primary"> </span>
<span className="text-xl font-bold">{item.score}</span>
<span className="text-sm font-medium text-ink-secondary">/20</span> {hasScore && item.nclc !== null ? (
</p> <NclcBadge nclc={item.nclc} />
<p className="text-xs text-ink-secondary tabular-nums"> ) : (
NCLC {item.nclc} <span className={`${CHIP_BASE} ${CHIP_NEUTRAL}`}>En cours</span>
{item.nclc_cible ? ` / cible ${item.nclc_cible}` : ''} )}
</p>
</div> <span className="min-w-[56px] text-right text-[16px] font-semibold tracking-[-0.02em] tabular-nums text-ink-primary">
) : ( {hasScore ? `${item.score}/20` : '—/20'}
<div className="shrink-0 text-right text-xs text-ink-secondary">Score à venir</div> </span>
)}
</div> <ChevronRight className="size-[14px] shrink-0 text-ink-tertiary" aria-hidden="true" />
</Link> </Link>
) )
} }

View file

@ -1,13 +1,14 @@
/** /**
* SimulationsList Sprint 3.7. * SimulationsList refonte Sprint 4.7.
* *
* Orchestre : * - Reçoit directement `items: SimulationListItem[]` (filtrés en amont par la
* - Loading / error / empty state * page) au lieu d'une réponse paginée. La pagination Précédent/Suivant a
* - Gating plan : Free aperçu flouté + upgrade ; Standard+ liste * é supprimée au profit du filtrage local (cf. HistoriquePage).
* - Pagination Précédent/Suivant (MVP) * - Conserve le gating Free (aperçu flouté + CTA upgrade Règle D).
* - Distingue état vide global (« aucune simulation ») vs filtré
* (« aucun résultat pour ces filtres »).
* *
* Règle D : passe par `hasAccess(plan, 'dashboard')` jamais de `plan === 'free'`. * Règle L : tokens DA Charcoal exclusivement.
* Règle L : tokens Direction H exclusivement.
*/ */
import { Lock } from 'lucide-react' import { Lock } from 'lucide-react'
@ -15,27 +16,24 @@ import { Link } from 'react-router-dom'
import { Card } from '@/shared/ui/Card' import { Card } from '@/shared/ui/Card'
import { Button } from '@/shared/ui/Button' import { Button } from '@/shared/ui/Button'
import { hasAccess, type Plan } from '@/entities/user/lib' import { hasAccess, type Plan } from '@/entities/user/lib'
import type { SimulationsListResponse } from '@/entities/production/types' import type { SimulationListItem as Item } from '@/entities/production/types'
import { SimulationListItem } from './SimulationListItem' import { SimulationListItem } from './SimulationListItem'
interface Props { interface Props {
plan: Plan plan: Plan
data: SimulationsListResponse | undefined items: Item[]
isLoading: boolean isLoading: boolean
isError: boolean isError: boolean
page: number /** True si au moins un filtre non-`all` est actif — distingue empty filtré vs global. */
limit: number isFiltered: boolean
onPrev: () => void
onNext: () => void
onUpgrade: () => 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 const PLACEHOLDER_WIDTHS = ['w-3/4', 'w-full', 'w-3/5', 'w-4/5'] as const
function BlurredPreview({ onUpgrade }: { onUpgrade: () => void }) { function BlurredPreview({ onUpgrade }: { onUpgrade: () => void }) {
return ( return (
<div className="relative min-h-[240px] overflow-hidden rounded-lg border border-border bg-surface"> <div className="relative min-h-[240px] overflow-hidden rounded-[12px] border border-border bg-surface">
<div className="space-y-3 p-4 opacity-25 blur-sm" aria-hidden="true"> <div className="space-y-3 p-4 opacity-25 blur-sm" aria-hidden="true">
{PLACEHOLDER_WIDTHS.map((w, i) => ( {PLACEHOLDER_WIDTHS.map((w, i) => (
<div key={i} className={`h-16 rounded bg-surface-hover ${w}`} /> <div key={i} className={`h-16 rounded bg-surface-hover ${w}`} />
@ -56,7 +54,7 @@ function ListSkeleton() {
return ( return (
<div className="space-y-3" aria-busy="true" aria-label="Chargement de l'historique…"> <div className="space-y-3" aria-busy="true" aria-label="Chargement de l'historique…">
{Array.from({ length: 5 }).map((_, i) => ( {Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-20 animate-pulse rounded-lg bg-surface" /> <div key={i} className="h-14 animate-pulse rounded-lg bg-surface" />
))} ))}
</div> </div>
) )
@ -80,6 +78,17 @@ function EmptyState() {
) )
} }
function EmptyFilteredState() {
return (
<Card variant="default" className="p-6 text-center">
<p className="text-sm text-ink-primary">Aucune simulation ne correspond à ces filtres.</p>
<p className="mt-1 text-xs text-ink-secondary">
Essayez d'élargir la période ou de changer la tâche sélectionnée.
</p>
</Card>
)
}
function ErrorState() { function ErrorState() {
return ( return (
<Card variant="default" className="border-l-4 border-l-danger p-4"> <Card variant="default" className="border-l-4 border-l-danger p-4">
@ -90,58 +99,27 @@ function ErrorState() {
) )
} }
export function SimulationsList({ export function SimulationsList({ plan, items, isLoading, isError, isFiltered, onUpgrade }: Props) {
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')) { if (!hasAccess(plan, 'dashboard')) {
return <BlurredPreview onUpgrade={onUpgrade} /> return <BlurredPreview onUpgrade={onUpgrade} />
} }
if (isError) return <ErrorState /> if (isError) return <ErrorState />
if (isLoading && !data) return <ListSkeleton /> if (isLoading) return <ListSkeleton />
if (!data) return null
if (data.data.length === 0) return <EmptyState /> if (items.length === 0) {
return isFiltered ? <EmptyFilteredState /> : <EmptyState />
const totalPages = Math.max(1, Math.ceil(data.pagination.total / limit)) }
const isFirst = page <= 1
const isLast = page >= totalPages
return ( return (
<div className="space-y-3"> <div className="overflow-hidden rounded-[12px] border border-border bg-surface shadow-card">
<ul className="space-y-3"> <ul>
{data.data.map((item) => ( {items.map((it, i) => (
<li key={item.id}> <li key={it.id}>
<SimulationListItem item={item} /> <SimulationListItem item={it} isLast={i === items.length - 1} />
</li> </li>
))} ))}
</ul> </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-secondary 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> </div>
) )
} }

View file

@ -1,12 +1,8 @@
/** /**
* Tests SimulationsList (Sprint 3.7). * Tests SimulationsList refonte Sprint 4.7.
* *
* Couvre : * Couvre : gating Free, état vide global, état vide filtré, items rendus,
* - État vide (Standard+) * isError, isLoading. La pagination a é retirée au Sprint 4.7.
* - 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 { describe, it, expect, vi, afterEach } from 'vitest'
@ -14,7 +10,7 @@ import { render, screen, cleanup } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom' import { MemoryRouter } from 'react-router-dom'
import { SimulationsList } from '../SimulationsList' import { SimulationsList } from '../SimulationsList'
import type { SimulationsListResponse } from '@/entities/production/types' import type { SimulationListItem } from '@/entities/production/types'
afterEach(cleanup) afterEach(cleanup)
@ -22,66 +18,52 @@ function renderWithRouter(ui: React.ReactNode) {
return render(<MemoryRouter>{ui}</MemoryRouter>) return render(<MemoryRouter>{ui}</MemoryRouter>)
} }
const EMPTY: SimulationsListResponse = { const ITEMS: SimulationListItem[] = [
data: [], {
pagination: { page: 1, limit: 20, total: 0 }, id: 'p1',
} tache: 'EE_T1',
mode: 'entrainement',
const THREE_ITEMS: SimulationsListResponse = { score: 14,
data: [ nclc: 9,
{ nclc_cible: 9,
id: 'p1', created_at: '2026-04-22T10:00:00Z',
tache: 'EE_T1', },
mode: 'entrainement', {
score: 14, id: 'p2',
nclc: 9, tache: 'EE_T2',
nclc_cible: 9, mode: 'examen',
created_at: '2026-04-22T10:00:00Z', score: 16,
}, nclc: 10,
{ nclc_cible: 10,
id: 'p2', created_at: '2026-04-22T09:00:00Z',
tache: 'EE_T2', },
mode: 'examen', {
score: 16, id: 'p3',
nclc: 10, tache: 'EE_T3',
nclc_cible: 10, mode: 'entrainement',
created_at: '2026-04-22T09:00:00Z', score: null,
}, nclc: null,
{ nclc_cible: null,
id: 'p3', created_at: '2026-04-22T08:00:00Z',
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 = () => {} const NOOP = () => {}
describe('SimulationsList — plan Free (gating)', () => { describe('SimulationsList — gating Free', () => {
it('affiche l\'aperçu flouté et le bouton "Passer en Standard"', () => { it("affiche l'aperçu flouté pour le plan Free", () => {
renderWithRouter( renderWithRouter(
<SimulationsList <SimulationsList
plan="free" plan="free"
data={THREE_ITEMS} items={ITEMS}
isLoading={false} isLoading={false}
isError={false} isError={false}
page={1} isFiltered={false}
limit={20}
onPrev={NOOP}
onNext={NOOP}
onUpgrade={NOOP} onUpgrade={NOOP}
/>, />,
) )
expect(screen.getByText(/historique disponible en standard/i)).toBeInTheDocument() expect(screen.getByText(/historique disponible en standard/i)).toBeInTheDocument()
expect(screen.getByRole('button', { name: /passer en standard/i })).toBeInTheDocument() expect(screen.queryByText(/EE · Tâche 1/i)).not.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 () => { it('clic sur "Passer en Standard" appelle onUpgrade', async () => {
@ -90,221 +72,115 @@ describe('SimulationsList — plan Free (gating)', () => {
renderWithRouter( renderWithRouter(
<SimulationsList <SimulationsList
plan="free" plan="free"
data={THREE_ITEMS} items={ITEMS}
isLoading={false} isLoading={false}
isError={false} isError={false}
page={1} isFiltered={false}
limit={20}
onPrev={NOOP}
onNext={NOOP}
onUpgrade={onUpgrade} onUpgrade={onUpgrade}
/>, />,
) )
await user.click(screen.getByRole('button', { name: /passer en standard/i })) await user.click(screen.getByRole('button', { name: /passer en standard/i }))
expect(onUpgrade).toHaveBeenCalledTimes(1) expect(onUpgrade).toHaveBeenCalledTimes(1)
}) })
}) })
describe('SimulationsList — plan Standard', () => { describe('SimulationsList — plan Standard', () => {
it('affiche l\'état vide avec le CTA "Démarrer une simulation"', () => { it('affiche l\'état vide global avec CTA "Démarrer une simulation"', () => {
renderWithRouter( renderWithRouter(
<SimulationsList <SimulationsList
plan="standard" plan="standard"
data={EMPTY} items={[]}
isLoading={false} isLoading={false}
isError={false} isError={false}
page={1} isFiltered={false}
limit={20}
onPrev={NOOP}
onNext={NOOP}
onUpgrade={NOOP} onUpgrade={NOOP}
/>, />,
) )
expect(screen.getByText(/aucune simulation pour le moment/i)).toBeInTheDocument()
expect(screen.getByText(/aucune simulation/i)).toBeInTheDocument()
expect(screen.getByRole('link', { name: /démarrer une simulation/i })).toHaveAttribute( expect(screen.getByRole('link', { name: /démarrer une simulation/i })).toHaveAttribute(
'href', 'href',
'/simulation/ee', '/simulation/ee',
) )
}) })
it('rend les items avec score, NCLC et badge "Examen" quand mode=examen', () => { it('affiche un état vide spécifique quand des filtres sont actifs', () => {
renderWithRouter( renderWithRouter(
<SimulationsList <SimulationsList
plan="standard" plan="standard"
data={THREE_ITEMS} items={[]}
isLoading={false} isLoading={false}
isError={false} isError={false}
page={1} isFiltered={true}
limit={20}
onPrev={NOOP}
onNext={NOOP}
onUpgrade={NOOP} onUpgrade={NOOP}
/>, />,
) )
expect(screen.getByText(/aucune simulation ne correspond à ces filtres/i)).toBeInTheDocument()
expect(screen.queryByRole('link', { name: /démarrer une simulation/i })).not.toBeInTheDocument()
})
// 3 items rendus it('rend les items avec libellé, score et badges', () => {
renderWithRouter(
<SimulationsList
plan="standard"
items={ITEMS}
isLoading={false}
isError={false}
isFiltered={false}
onUpgrade={NOOP}
/>,
)
const links = screen.getAllByRole('link') const links = screen.getAllByRole('link')
expect(links).toHaveLength(3) expect(links).toHaveLength(3)
// Item p1 : score 14, NCLC 9 / cible 9 expect(screen.getByText('EE · Tâche 1')).toBeInTheDocument()
expect(screen.getByText('14')).toBeInTheDocument() expect(screen.getByText('Examen blanc EE')).toBeInTheDocument()
expect(screen.getByText(/NCLC 9 \/ cible 9/)).toBeInTheDocument() expect(screen.getByText('14/20')).toBeInTheDocument()
// Item p2 (examen) : badge Examen expect(screen.getByText('NCLC 9')).toBeInTheDocument()
expect(screen.getByText('Examen')).toBeInTheDocument() expect(screen.getByText(/En cours/i)).toBeInTheDocument()
// Item p3 (score null) : badge "En cours" expect(screen.getByText('—/20')).toBeInTheDocument()
expect(screen.getByText('En cours')).toBeInTheDocument()
}) })
it('chaque item pointe vers /rapport/:id', () => { it('chaque item pointe vers /rapport/:id', () => {
renderWithRouter( renderWithRouter(
<SimulationsList <SimulationsList
plan="standard" plan="standard"
data={THREE_ITEMS} items={ITEMS}
isLoading={false} isLoading={false}
isError={false} isError={false}
page={1} isFiltered={false}
limit={20}
onPrev={NOOP}
onNext={NOOP}
onUpgrade={NOOP} onUpgrade={NOOP}
/>, />,
) )
const link = screen.getAllByRole('link')[0]
expect(screen.getByRole('link', { name: /expression écrite.*tâche 1/i })).toHaveAttribute( expect(link).toHaveAttribute('href', '/rapport/p1')
'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', () => { describe('SimulationsList — états transverses', () => {
it("isError → affiche le callout d'erreur", () => { it("isError → callout d'erreur", () => {
renderWithRouter( renderWithRouter(
<SimulationsList <SimulationsList
plan="standard" plan="standard"
data={undefined} items={[]}
isLoading={false} isLoading={false}
isError={true} isError={true}
page={1} isFiltered={false}
limit={20}
onPrev={NOOP}
onNext={NOOP}
onUpgrade={NOOP} onUpgrade={NOOP}
/>, />,
) )
expect(screen.getByRole('alert')).toHaveTextContent(/impossible de charger/i) expect(screen.getByRole('alert')).toHaveTextContent(/impossible de charger/i)
}) })
it('isLoading + pas de data → squelettes', () => { it('isLoading → squelettes', () => {
renderWithRouter( renderWithRouter(
<SimulationsList <SimulationsList
plan="standard" plan="standard"
data={undefined} items={[]}
isLoading={true} isLoading={true}
isError={false} isError={false}
page={1} isFiltered={false}
limit={20}
onPrev={NOOP}
onNext={NOOP}
onUpgrade={NOOP} onUpgrade={NOOP}
/>, />,
) )
expect(screen.getByLabelText(/chargement de l'historique/i)).toBeInTheDocument() expect(screen.getByLabelText(/chargement de l'historique/i)).toBeInTheDocument()
}) })
}) })

View file

@ -0,0 +1,158 @@
/**
* Logique pure de la page /historique Sprint 4.7.
*
* Toutes les fonctions ici sont déterministes et acceptent `now` injecté
* pour permettre des tests reproductibles. Aucune dépendance React, aucune
* I/O Règle H respectée (ces helpers pourraient vivre en `entities/`,
* mais ils sont 100 % spécifiques à la page historique restent ici).
*
* Filtrage côté frontend uniquement : les 50 simulations les plus récentes
* sont chargées en une fois (cf. HistoriquePage), puis filtrées localement.
*/
import type { SimulationListItem, Tache } from '@/entities/production/types'
export type TaskFilter = 'all' | Tache
export type PeriodFilter = 'all' | 'this-month' | '3-months'
export interface FiltersState {
task: TaskFilter
period: PeriodFilter
}
const DAY_MS = 24 * 60 * 60 * 1000
function isInPeriod(iso: string, period: PeriodFilter, now: Date): boolean {
if (period === 'all') return true
const d = new Date(iso)
if (!Number.isFinite(d.getTime())) return false
if (period === 'this-month') {
return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth()
}
// 3-months : 90 jours glissants
return now.getTime() - d.getTime() <= 90 * DAY_MS
}
export function applyFilters(
items: SimulationListItem[],
{ task, period }: FiltersState,
now: Date,
): SimulationListItem[] {
return items.filter((it) => {
if (task !== 'all' && it.tache !== task) return false
if (!isInPeriod(it.created_at, period, now)) return false
return true
})
}
export interface HistoriqueStats {
total: number
thisMonth: number
average: number | null
best: { score: number; tache: Tache; created_at: string } | null
}
export function computeStats(items: SimulationListItem[], now: Date): HistoriqueStats {
const total = items.length
const thisMonth = items.filter((it) => isInPeriod(it.created_at, 'this-month', now)).length
const scored = items.filter(
(it): it is SimulationListItem & { score: number } => typeof it.score === 'number',
)
if (scored.length === 0) {
return { total, thisMonth, average: null, best: null }
}
const sum = scored.reduce((acc, it) => acc + it.score, 0)
const average = sum / scored.length
const bestItem = scored.reduce((acc, it) => (it.score > acc.score ? it : acc), scored[0])
return {
total,
thisMonth,
average,
best: { score: bestItem.score, tache: bestItem.tache, created_at: bestItem.created_at },
}
}
export interface Trend {
direction: 'up' | 'down'
delta: number
}
/**
* Tendance 30 j : moyenne des 30 derniers jours vs 30 j précédents (j-60 j-30).
* Ignore les items sans score. Retourne `null` si l'une des deux fenêtres est vide.
*/
export function computeTrend(items: SimulationListItem[], now: Date): Trend | null {
const t = now.getTime()
const within = (iso: string, fromDays: number, toDays: number) => {
const ts = new Date(iso).getTime()
if (!Number.isFinite(ts)) return false
const ageMs = t - ts
return ageMs >= fromDays * DAY_MS && ageMs < toDays * DAY_MS
}
const recent = items.filter(
(it): it is SimulationListItem & { score: number } =>
typeof it.score === 'number' && within(it.created_at, 0, 30),
)
const previous = items.filter(
(it): it is SimulationListItem & { score: number } =>
typeof it.score === 'number' && within(it.created_at, 30, 60),
)
if (recent.length === 0 || previous.length === 0) return null
const avg = (xs: { score: number }[]) => xs.reduce((s, it) => s + it.score, 0) / xs.length
const delta = avg(recent) - avg(previous)
if (delta === 0) return { direction: 'up', delta: 0 }
return { direction: delta > 0 ? 'up' : 'down', delta: Math.abs(delta) }
}
const SHORT_DATE = new Intl.DateTimeFormat('fr-FR', { day: 'numeric', month: 'short' })
/** Date courte type "22 avr." (locale fr-FR). */
export function formatShortDate(iso: string): string {
const d = new Date(iso)
if (!Number.isFinite(d.getTime())) return ''
return SHORT_DATE.format(d)
}
const TACHE_NUMBER: Record<Tache, string> = {
EE_T1: 'EE · Tâche 1',
EE_T2: 'EE · Tâche 2',
EE_T3: 'EE · Tâche 3',
EO_T1: 'EO · Tâche 1',
EO_T3: 'EO · Tâche 3',
}
/**
* Libellé court d'un item d'historique selon la maquette Sprint 4.7.
*
* Mode entraînement "EE · Tâche 3"
* Mode examen "Examen blanc EE" / "Examen blanc EO"
*/
export function formatTaskLabel(item: Pick<SimulationListItem, 'tache' | 'mode'>): string {
if (item.mode === 'examen') {
return item.tache.startsWith('EE_') ? 'Examen blanc EE' : 'Examen blanc EO'
}
return TACHE_NUMBER[item.tache]
}
export type NclcChip = 'ok' | 'warn' | 'err'
/**
* Variante visuelle du badge NCLC selon le seuil :
* - 9 ok (success)
* - 7-8 warn (warning/gold)
* - 6 err (danger)
*/
export function nclcChipVariant(nclc: number): NclcChip {
if (nclc >= 9) return 'ok'
if (nclc >= 7) return 'warn'
return 'err'
}

View file

@ -1,40 +1,88 @@
/** /**
* Page /historique liste paginée des simulations de l'utilisateur connecté. * Page /historique refonte Sprint 4.7.
* *
* Consommateurs amont : * Charge en une fois les 50 simulations les plus récentes via
* - `usePlan` (cache partagé avec dashboard/simulation) * `useSimulationsList(1, 50)` puis applique les filtres (tâche + période)
* - `useSimulationsList(page, limit)` cache `['simulations', 'list', p, l]` * côté frontend. Cette limite est volontaire : un MVP avec un volume modeste
* d'utilisateurs ne nécessite pas de filtrage backend. Au-delà de 50, les
* simulations plus anciennes ne sont pas accessibles tant que les filtres
* ne sont pas reportés côté backend (`GET /simulations?tache=&since=`).
* *
* Gating Free via `hasAccess(plan, 'dashboard')` délégué à `SimulationsList`. * Règle D : gating via `hasAccess(plan, 'dashboard')` dans `SimulationsList`.
* Pagination : Précédent/Suivant via state local `page`. * Règle L : tokens DA Charcoal exclusivement.
*
* Règle H : aucune logique métier toute l'orchestration vit dans SimulationsList.
*/ */
import { useState } from 'react' import { useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { usePlan } from '@/features/dashboard/hooks/usePlan' import { usePlan } from '@/features/dashboard/hooks/usePlan'
import { useSimulationsList } from '../hooks/useSimulationsList' import { useSimulationsList } from '../hooks/useSimulationsList'
import { SimulationsList } from '../components/SimulationsList' import { SimulationsList } from '../components/SimulationsList'
import { HistoriqueStatsCards } from '../components/HistoriqueStats'
import { HistoriqueFilters } from '../components/HistoriqueFilters'
import {
applyFilters,
computeStats,
computeTrend,
type PeriodFilter,
type TaskFilter,
} from '../lib/historique'
const PAGE_SIZE = 20 const LIMIT = 50
export function HistoriquePage() { export function HistoriquePage() {
const navigate = useNavigate() const navigate = useNavigate()
const [page, setPage] = useState(1) const [task, setTask] = useState<TaskFilter>('all')
const [period, setPeriod] = useState<PeriodFilter>('this-month')
const { data: planData, isLoading: isPlanLoading } = usePlan() const { data: planData, isLoading: isPlanLoading } = usePlan()
const { data, isLoading, isError } = useSimulationsList(page, PAGE_SIZE) const { data, isLoading, isError } = useSimulationsList(1, LIMIT)
const now = useMemo(() => new Date(), [])
const allItems = data?.data ?? []
const filtered = useMemo(
() => applyFilters(allItems, { task, period }, now),
[allItems, task, period, now],
)
const stats = useMemo(() => computeStats(filtered, now), [filtered, now])
const trend = useMemo(() => computeTrend(filtered, now), [filtered, now])
const isFiltered = task !== 'all' || period !== 'all'
const showStats = !isPlanLoading && planData && !isError
const canSeeContent = planData && planData.plan !== 'free'
// AppLayout fournit déjà mx-auto max-w-[1100px] + lg:px-9 lg:py-9 (cf.
// AppLayout.tsx) — on limite ici à 860 px sans réintroduire de padding
// pour éviter le double margin.
return ( return (
<main className="mx-auto max-w-3xl space-y-6 px-4 py-6"> <main className="mx-auto w-full max-w-[860px]">
<header className="space-y-1"> <header className="mb-6 flex flex-wrap items-end justify-between gap-4">
<h1 className="text-lg font-semibold text-ink-primary">Historique</h1> <div>
<p className="text-sm text-ink-secondary"> <p className="text-[11px] font-semibold uppercase tracking-[0.073em] text-ink-tertiary">
Retrouvez toutes vos simulations passées et leur progression. Historique
</p> </p>
<h1 className="mt-1 text-[24px] font-bold tracking-[-0.02em] text-ink-primary">
Mes simulations
</h1>
<p className="mt-1 text-[13.5px] text-ink-secondary">
Retrouve toutes tes simulations avec leur score et leur rapport.
</p>
</div>
{canSeeContent && (
<HistoriqueFilters
task={task}
period={period}
onTaskChange={setTask}
onPeriodChange={setPeriod}
/>
)}
</header> </header>
{showStats && canSeeContent && (
<div className="mb-6">
<HistoriqueStatsCards stats={stats} trend={trend} />
</div>
)}
{isPlanLoading || !planData ? ( {isPlanLoading || !planData ? (
<div className="space-y-3" aria-busy="true"> <div className="space-y-3" aria-busy="true">
<div className="h-20 animate-pulse rounded-lg bg-surface" /> <div className="h-20 animate-pulse rounded-lg bg-surface" />
@ -43,13 +91,10 @@ export function HistoriquePage() {
) : ( ) : (
<SimulationsList <SimulationsList
plan={planData.plan} plan={planData.plan}
data={data} items={filtered}
isLoading={isLoading} isLoading={isLoading}
isError={isError} isError={isError}
page={page} isFiltered={isFiltered}
limit={PAGE_SIZE}
onPrev={() => setPage((p) => Math.max(1, p - 1))}
onNext={() => setPage((p) => p + 1)}
onUpgrade={() => navigate('/plan')} onUpgrade={() => navigate('/plan')}
/> />
)} )}

View file

@ -36,38 +36,41 @@ export function ProgressionPage() {
const isPremium = planData ? hasAccess(planData.plan, 'pattern_analysis') : false const isPremium = planData ? hasAccess(planData.plan, 'pattern_analysis') : false
return ( return (
<main className="mx-auto max-w-3xl space-y-6 px-4 py-6"> <div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
<header className="space-y-1"> <main className="mx-auto max-w-3xl space-y-6 px-4 py-6">
<h1 className="text-lg font-semibold text-ink-primary">Profil de préparation</h1> <header className="space-y-1">
<p className="text-sm text-ink-secondary"> <h1 className="text-lg font-semibold text-ink-primary">Profil de préparation</h1>
Repérez vos erreurs récurrentes et travaillez-les avec des exercices ciblés. <p className="text-sm text-ink-secondary">
</p> Repérez vos erreurs récurrentes et travaillez-les avec des exercices ciblés.
</header> </p>
</header>
{isPlanLoading && <Skeleton />} {isPlanLoading && <Skeleton />}
{!isPlanLoading && planData && !isPremium && ( {!isPlanLoading && planData && !isPremium && (
<BlurredProgression onUpgrade={() => navigate('/plan')} /> <BlurredProgression onUpgrade={() => navigate('/plan')} />
)} )}
{!isPlanLoading && planData && isPremium && ( {!isPlanLoading && planData && isPremium && (
<> <>
{isPatternsLoading && <Skeleton />} {isPatternsLoading && <Skeleton />}
{isError && ( {isError && (
<Card variant="default" className="border-l-4 border-l-danger p-4"> <Card variant="default" className="border-l-4 border-l-danger p-4">
<p className="text-sm text-danger" role="alert"> <p className="text-sm text-danger" role="alert">
Impossible de charger votre profil de préparation. Réessayez dans quelques instants. Impossible de charger votre profil de préparation. Réessayez dans quelques
</p> instants.
<div className="mt-3"> </p>
<Button variant="secondary" size="sm" onClick={() => navigate(0)}> <div className="mt-3">
Rafraîchir <Button variant="secondary" size="sm" onClick={() => navigate(0)}>
</Button> Rafraîchir
</div> </Button>
</Card> </div>
)} </Card>
{patternsData && <ProgressionPremium data={patternsData} />} )}
</> {patternsData && <ProgressionPremium data={patternsData} />}
)} </>
</main> )}
</main>
</div>
) )
} }

View file

@ -91,81 +91,85 @@ export function EnregistrementEOPage() {
const lockControls = submitting || isCorrecting const lockControls = submitting || isCorrecting
return ( return (
<main className="mx-auto max-w-3xl px-4 py-6"> <div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> <main className="mx-auto max-w-3xl px-4 py-6">
<h2 className="text-lg font-semibold text-ink-primary">{formatTache(production.tache)}</h2> <div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<Badge variant="neutral" className="inline-flex items-center gap-1.5"> <h2 className="text-lg font-semibold text-ink-primary">
<Timer className="size-3.5" aria-hidden="true" /> {formatTache(production.tache)}
Durée recommandée : {formatTimer(dureeRecommandee)} </h2>
</Badge> <Badge variant="neutral" className="inline-flex items-center gap-1.5">
</div> <Timer className="size-3.5" aria-hidden="true" />
Durée recommandée : {formatTimer(dureeRecommandee)}
</Badge>
</div>
{/* T1 affiche la présentation générée comme texte de référence à lire. {/* T1 affiche la présentation générée comme texte de référence à lire.
T3 affiche le sujet sélectionné. Les deux sont mutuellement exclusifs. */} T3 affiche le sujet sélectionné. Les deux sont mutuellement exclusifs. */}
{production.tache === 'EO_T1' && presentationT1 && ( {production.tache === 'EO_T1' && presentationT1 && (
<section <section
className="mb-6 rounded-lg border border-border bg-surface-solid p-4" className="mb-6 rounded-lg border border-border bg-surface-solid p-4"
aria-label="Texte de présentation de référence" aria-label="Texte de présentation de référence"
> >
<p className="mb-2 text-[11px] font-semibold uppercase tracking-widest text-ink-tertiary"> <p className="mb-2 text-[11px] font-semibold uppercase tracking-widest text-ink-tertiary">
Ta présentation (référence) Ta présentation (référence)
</p>
<div className="max-h-48 overflow-y-auto whitespace-pre-wrap text-sm leading-relaxed text-ink-secondary">
{presentationT1}
</div>
</section>
)}
{production.tache !== 'EO_T1' && sujet && (
<div className="mb-6">
<SujetDisplay sujet={sujet} />
</div>
)}
<AudioRecorder
minSeconds={minSeconds}
maxSeconds={dureeRecommandee || undefined}
downloadFilename={`expria-${production.tache.toLowerCase()}-${production.id.slice(0, 8)}`}
onSubmit={handleSubmit}
onCancel={handleCancel}
autoStart
disabled={lockControls}
/>
{lockControls && (
<div
role="status"
aria-live="polite"
className="mt-4 flex items-start gap-2 rounded-md border border-brand/30 bg-brand/10 px-4 py-3 text-sm text-brand-text"
>
<Loader2 className="mt-0.5 size-4 shrink-0 animate-spin" aria-hidden="true" />
<div>
<p className="font-medium">Transcription et correction en cours</p>
<p className="mt-0.5 text-xs">
Cela peut prendre jusqu'à 60 secondes. Tu seras redirigé vers le rapport
automatiquement.
</p> </p>
<div className="max-h-48 overflow-y-auto whitespace-pre-wrap text-sm leading-relaxed text-ink-secondary">
{presentationT1}
</div>
</section>
)}
{production.tache !== 'EO_T1' && sujet && (
<div className="mb-6">
<SujetDisplay sujet={sujet} />
</div> </div>
</div> )}
)}
{encodingError && ( <AudioRecorder
<div minSeconds={minSeconds}
role="alert" maxSeconds={dureeRecommandee || undefined}
className="mt-4 rounded-md border border-danger/40 bg-danger-soft px-3 py-2 text-sm text-danger" downloadFilename={`expria-${production.tache.toLowerCase()}-${production.id.slice(0, 8)}`}
> onSubmit={handleSubmit}
{encodingError} onCancel={handleCancel}
</div> autoStart
)} disabled={lockControls}
/>
{correctError && !lockControls && ( {lockControls && (
<div <div
role="alert" role="status"
className="mt-4 rounded-md border border-danger/40 bg-danger-soft px-3 py-2 text-sm text-danger" aria-live="polite"
> className="mt-4 flex items-start gap-2 rounded-md border border-brand/30 bg-brand/10 px-4 py-3 text-sm text-brand-text"
La correction a échoué. Réessayez dans quelques instants. >
</div> <Loader2 className="mt-0.5 size-4 shrink-0 animate-spin" aria-hidden="true" />
)} <div>
</main> <p className="font-medium">Transcription et correction en cours</p>
<p className="mt-0.5 text-xs">
Cela peut prendre jusqu'à 60 secondes. Tu seras redirigé vers le rapport
automatiquement.
</p>
</div>
</div>
)}
{encodingError && (
<div
role="alert"
className="mt-4 rounded-md border border-danger/40 bg-danger-soft px-3 py-2 text-sm text-danger"
>
{encodingError}
</div>
)}
{correctError && !lockControls && (
<div
role="alert"
className="mt-4 rounded-md border border-danger/40 bg-danger-soft px-3 py-2 text-sm text-danger"
>
La correction a échoué. Réessayez dans quelques instants.
</div>
)}
</main>
</div>
) )
} }

View file

@ -29,50 +29,54 @@ export function ModeChoixT1Page() {
if (shouldRedirect) return null if (shouldRedirect) return null
return ( return (
<main className="mx-auto max-w-3xl px-4 py-6"> <div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
<div className="mb-2 flex items-center gap-3"> <main className="mx-auto max-w-3xl px-4 py-6">
<button <div className="mb-2 flex items-center gap-3">
type="button" <button
onClick={() => { type="button"
reset() onClick={() => {
navigate('/simulation/eo') reset()
}} navigate('/simulation/eo')
className="text-sm text-ink-secondary underline-offset-4 hover:text-ink-primary hover:underline" }}
> className="text-sm text-ink-secondary underline-offset-4 hover:text-ink-primary hover:underline"
Retour >
</button> Retour
</div> </button>
</div>
<h2 className="text-lg font-semibold text-ink-primary">Tâche 1 Présentation personnelle</h2> <h2 className="text-lg font-semibold text-ink-primary">
<p className="mt-1 mb-6 text-sm text-ink-secondary">Choisis ton mode d'entraînement.</p> Tâche 1 Présentation personnelle
</h2>
<p className="mt-1 mb-6 text-sm text-ink-secondary">Choisis ton mode d'entraînement.</p>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Card <Card
variant="interactive" variant="interactive"
className="flex flex-col gap-3 p-5" className="flex flex-col gap-3 p-5"
onClick={() => navigate('/simulation/eo/t1/questionnaire')} onClick={() => navigate('/simulation/eo/t1/questionnaire')}
> >
<Sparkles className="size-6 text-brand-text" aria-hidden="true" /> <Sparkles className="size-6 text-brand-text" aria-hidden="true" />
<h3 className="text-base font-semibold text-ink-primary">Générer ma présentation</h3> <h3 className="text-base font-semibold text-ink-primary">Générer ma présentation</h3>
<p className="text-sm leading-relaxed text-ink-secondary"> <p className="text-sm leading-relaxed text-ink-secondary">
Réponds à 5 questions Expria génère ton texte personnalisé que tu lis avant Réponds à 5 questions Expria génère ton texte personnalisé que tu lis avant
d'enregistrer. d'enregistrer.
</p> </p>
</Card> </Card>
<Card <Card
variant="interactive" variant="interactive"
className="flex flex-col gap-3 p-5" className="flex flex-col gap-3 p-5"
onClick={() => navigate('/simulation/eo/pre-enregistrement')} onClick={() => navigate('/simulation/eo/pre-enregistrement')}
> >
<Mic className="size-6 text-brand-text" aria-hidden="true" /> <Mic className="size-6 text-brand-text" aria-hidden="true" />
<h3 className="text-base font-semibold text-ink-primary">Enregistrer directement</h3> <h3 className="text-base font-semibold text-ink-primary">Enregistrer directement</h3>
<p className="text-sm leading-relaxed text-ink-secondary"> <p className="text-sm leading-relaxed text-ink-secondary">
Tu as déjà préparé ta présentation enregistre-toi directement sans passer par le Tu as déjà préparé ta présentation enregistre-toi directement sans passer par le
formulaire. formulaire.
</p> </p>
</Card> </Card>
</div> </div>
</main> </main>
</div>
) )
} }

View file

@ -51,55 +51,59 @@ export function PreEnregistrementEOPage() {
} }
return ( return (
<main className="mx-auto max-w-3xl px-4 py-6"> <div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> <main className="mx-auto max-w-3xl px-4 py-6">
<h2 className="text-lg font-semibold text-ink-primary">{heading}</h2> <div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<Badge variant="neutral" className="inline-flex items-center gap-1.5"> <h2 className="text-lg font-semibold text-ink-primary">{heading}</h2>
<Timer className="size-3.5" aria-hidden="true" /> <Badge variant="neutral" className="inline-flex items-center gap-1.5">
Durée recommandée : {formatTimer(dureeRecommandee)} <Timer className="size-3.5" aria-hidden="true" />
</Badge> Durée recommandée : {formatTimer(dureeRecommandee)}
</div> </Badge>
{sujet && !isT1 && (
<div className="mb-6">
<SujetDisplay sujet={sujet} />
</div> </div>
)}
<div className="mb-6 rounded-lg border border-border bg-surface p-4 text-sm text-ink-secondary"> {sujet && !isT1 && (
<p className="font-medium text-ink-primary">Avant de commencer</p> <div className="mb-6">
<ul className="mt-2 list-disc space-y-1 pl-5"> <SujetDisplay sujet={sujet} />
<li>Trouvez un endroit calme : la transcription est plus précise sans bruit de fond.</li> </div>
<li>Autorisez l'accès au micro dans votre navigateur lorsque demandé.</li>
<li>Parlez de manière naturelle. La durée est indicative, pas un cap.</li>
{isT1 && (
<li>
Présentez-vous à l'examinateur : prénom, parcours, situation familiale, loisirs,
projet d'immigration au Canada.
</li>
)}
<li>
Vous pourrez télécharger votre enregistrement à la fin il n'est pas conservé sur nos
serveurs.
</li>
</ul>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button
variant="primary"
size="lg"
icon={<Mic className="size-4" aria-hidden="true" />}
onClick={handleStart}
>
Démarrer l'enregistrement
</Button>
{isT3 && (
<Button variant="secondary" size="md" onClick={handleChangeSujet}>
Changer de sujet
</Button>
)} )}
</div>
</main> <div className="mb-6 rounded-lg border border-border bg-surface p-4 text-sm text-ink-secondary">
<p className="font-medium text-ink-primary">Avant de commencer</p>
<ul className="mt-2 list-disc space-y-1 pl-5">
<li>
Trouvez un endroit calme : la transcription est plus précise sans bruit de fond.
</li>
<li>Autorisez l'accès au micro dans votre navigateur lorsque demandé.</li>
<li>Parlez de manière naturelle. La durée est indicative, pas un cap.</li>
{isT1 && (
<li>
Présentez-vous à l'examinateur : prénom, parcours, situation familiale, loisirs,
projet d'immigration au Canada.
</li>
)}
<li>
Vous pourrez télécharger votre enregistrement à la fin il n'est pas conservé sur nos
serveurs.
</li>
</ul>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button
variant="primary"
size="lg"
icon={<Mic className="size-4" aria-hidden="true" />}
onClick={handleStart}
>
Démarrer l'enregistrement
</Button>
{isT3 && (
<Button variant="secondary" size="md" onClick={handleChangeSujet}>
Changer de sujet
</Button>
)}
</div>
</main>
</div>
) )
} }

View file

@ -108,91 +108,93 @@ export function PresentationGenereeT1Page() {
} }
return ( return (
<main className="mx-auto max-w-3xl px-4 py-6"> <div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"> <main className="mx-auto max-w-3xl px-4 py-6">
<div> <div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<h2 className="text-lg font-semibold text-ink-primary">Ta présentation générée</h2> <div>
<p className="mt-1 text-sm text-ink-secondary"> <h2 className="text-lg font-semibold text-ink-primary">Ta présentation générée</h2>
Lis-la, modifie-la si nécessaire, puis enregistre-toi. <p className="mt-1 text-sm text-ink-secondary">
</p> Lis-la, modifie-la si nécessaire, puis enregistre-toi.
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button
variant="secondary"
size="sm"
icon={
copied ? (
<Check className="size-4" aria-hidden="true" />
) : (
<Copy className="size-4" aria-hidden="true" />
)
}
onClick={handleCopy}
>
{copied ? 'Copié' : 'Copier'}
</Button>
<Button
variant="secondary"
size="sm"
icon={<Download className="size-4" aria-hidden="true" />}
onClick={handleDownload}
>
.txt
</Button>
<Button
variant="secondary"
size="sm"
icon={
isEditing ? (
<Save className="size-4" aria-hidden="true" />
) : (
<Pencil className="size-4" aria-hidden="true" />
)
}
onClick={handleToggleEdit}
>
{isEditing ? 'Enregistrer les modifications' : 'Modifier'}
</Button>
</div>
</div> </div>
<div className="flex flex-wrap gap-2">
<Button
variant="secondary"
size="sm"
icon={
copied ? (
<Check className="size-4" aria-hidden="true" />
) : (
<Copy className="size-4" aria-hidden="true" />
)
}
onClick={handleCopy}
>
{copied ? 'Copié' : 'Copier'}
</Button>
<Button
variant="secondary"
size="sm"
icon={<Download className="size-4" aria-hidden="true" />}
onClick={handleDownload}
>
.txt
</Button>
<Button
variant="secondary"
size="sm"
icon={
isEditing ? (
<Save className="size-4" aria-hidden="true" />
) : (
<Pencil className="size-4" aria-hidden="true" />
)
}
onClick={handleToggleEdit}
>
{isEditing ? 'Enregistrer les modifications' : 'Modifier'}
</Button>
</div>
</div>
<textarea <textarea
value={text} value={text}
onChange={(e) => setText(e.target.value)} onChange={(e) => setText(e.target.value)}
readOnly={!isEditing} readOnly={!isEditing}
rows={12} rows={12}
className="w-full rounded-lg border border-border bg-surface p-4 text-sm leading-relaxed text-ink-primary focus:border-brand focus:outline-none focus:shadow-focus disabled:cursor-not-allowed read-only:bg-surface-solid" className="w-full rounded-lg border border-border bg-surface p-4 text-sm leading-relaxed text-ink-primary focus:border-brand focus:outline-none focus:shadow-focus disabled:cursor-not-allowed read-only:bg-surface-solid"
/> />
<div <div
role="note" role="note"
className="mt-4 rounded-md border border-warning/40 bg-warning-soft px-4 py-3 text-sm text-ink-primary" className="mt-4 rounded-md border border-warning/40 bg-warning-soft px-4 py-3 text-sm text-ink-primary"
>
<strong className="text-warning">Conseil :</strong> Lis ce texte à voix haute 2-3 fois avant
d'enregistrer. Vise un débit naturel, ni trop rapide ni trop lent.
</div>
<div className="mt-3 flex flex-col gap-2 rounded-md border border-brand/30 bg-brand/10 px-4 py-3 text-sm text-brand-text sm:flex-row sm:items-center sm:justify-between">
<span>Présentation sauvegardée retrouvée automatiquement à ta prochaine visite.</span>
<button
type="button"
onClick={handleRefaire}
className="inline-flex items-center gap-1.5 text-sm font-medium underline underline-offset-4 hover:text-brand-text"
> >
<RotateCcw className="size-3.5" aria-hidden="true" /> <strong className="text-warning">Conseil :</strong> Lis ce texte à voix haute 2-3 fois
Refaire avant d'enregistrer. Vise un débit naturel, ni trop rapide ni trop lent.
</button> </div>
</div>
<Button <div className="mt-3 flex flex-col gap-2 rounded-md border border-brand/30 bg-brand/10 px-4 py-3 text-sm text-brand-text sm:flex-row sm:items-center sm:justify-between">
variant="primary" <span>Présentation sauvegardée retrouvée automatiquement à ta prochaine visite.</span>
size="lg" <button
icon={<Mic className="size-4" aria-hidden="true" />} type="button"
onClick={handleStartRecording} onClick={handleRefaire}
className="mt-6 w-full" className="inline-flex items-center gap-1.5 text-sm font-medium underline underline-offset-4 hover:text-brand-text"
> >
Je suis prêt Enregistrer <RotateCcw className="size-3.5" aria-hidden="true" />
</Button> Refaire
</main> </button>
</div>
<Button
variant="primary"
size="lg"
icon={<Mic className="size-4" aria-hidden="true" />}
onClick={handleStartRecording}
className="mt-6 w-full"
>
Je suis prêt Enregistrer
</Button>
</main>
</div>
) )
} }

View file

@ -158,86 +158,88 @@ export function QuestionnaireT1Page() {
if (shouldRedirect) return null if (shouldRedirect) return null
return ( return (
<main className="mx-auto max-w-2xl px-4 py-6"> <div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
<div className="mb-2 flex items-center gap-3"> <main className="mx-auto max-w-2xl px-4 py-6">
<button <div className="mb-2 flex items-center gap-3">
type="button" <button
onClick={() => navigate('/simulation/eo/t1/mode')} type="button"
className="text-sm text-ink-secondary underline-offset-4 hover:text-ink-primary hover:underline" onClick={() => navigate('/simulation/eo/t1/mode')}
disabled={mutation.isPending} className="text-sm text-ink-secondary underline-offset-4 hover:text-ink-primary hover:underline"
> disabled={mutation.isPending}
Retour
</button>
</div>
<h2 className="text-lg font-semibold text-ink-primary">Tâche 1 Questionnaire</h2>
<p className="mt-1 mb-6 text-sm text-ink-secondary">
Réponds brièvement à ces 5 questions. Ta présentation personnalisée sera générée
automatiquement.
</p>
<form onSubmit={handleSubmit} className="space-y-5" noValidate>
{FIELDS.map((field) => {
const value = reponses[field.key]
const showError = touched[field.key] && fieldErrors[field.key]
const id = `q-${field.key}`
return (
<div key={field.key} className="space-y-1.5">
<label htmlFor={id} className="block text-sm font-medium text-ink-primary">
{field.label}
</label>
{field.multiline ? (
<textarea
id={id}
value={value}
onChange={(e) => handleChange(field.key, e.target.value)}
onBlur={() => handleBlur(field.key)}
placeholder={field.placeholder}
maxLength={FIELD_MAX}
rows={2}
className={inputBase}
/>
) : (
<input
id={id}
type="text"
value={value}
onChange={(e) => handleChange(field.key, e.target.value)}
onBlur={() => handleBlur(field.key)}
placeholder={field.placeholder}
maxLength={FIELD_MAX}
className={inputBase}
/>
)}
{showError && (
<p className="text-xs text-danger" role="alert">
{fieldErrors[field.key]}
</p>
)}
</div>
)
})}
{apiErrorMessage && (
<div
role="alert"
className="rounded-md border border-danger/40 bg-danger-soft px-3 py-2 text-sm text-danger"
> >
{apiErrorMessage} Retour
</div> </button>
)} </div>
<Button <h2 className="text-lg font-semibold text-ink-primary">Tâche 1 Questionnaire</h2>
type="submit" <p className="mt-1 mb-6 text-sm text-ink-secondary">
variant="primary" Réponds brièvement à ces 5 questions. Ta présentation personnalisée sera générée
size="lg" automatiquement.
icon={<Sparkles className="size-4" aria-hidden="true" />} </p>
loading={mutation.isPending}
disabled={!formValid || mutation.isPending} <form onSubmit={handleSubmit} className="space-y-5" noValidate>
> {FIELDS.map((field) => {
Générer ma présentation const value = reponses[field.key]
</Button> const showError = touched[field.key] && fieldErrors[field.key]
</form> const id = `q-${field.key}`
</main> return (
<div key={field.key} className="space-y-1.5">
<label htmlFor={id} className="block text-sm font-medium text-ink-primary">
{field.label}
</label>
{field.multiline ? (
<textarea
id={id}
value={value}
onChange={(e) => handleChange(field.key, e.target.value)}
onBlur={() => handleBlur(field.key)}
placeholder={field.placeholder}
maxLength={FIELD_MAX}
rows={2}
className={inputBase}
/>
) : (
<input
id={id}
type="text"
value={value}
onChange={(e) => handleChange(field.key, e.target.value)}
onBlur={() => handleBlur(field.key)}
placeholder={field.placeholder}
maxLength={FIELD_MAX}
className={inputBase}
/>
)}
{showError && (
<p className="text-xs text-danger" role="alert">
{fieldErrors[field.key]}
</p>
)}
</div>
)
})}
{apiErrorMessage && (
<div
role="alert"
className="rounded-md border border-danger/40 bg-danger-soft px-3 py-2 text-sm text-danger"
>
{apiErrorMessage}
</div>
)}
<Button
type="submit"
variant="primary"
size="lg"
icon={<Sparkles className="size-4" aria-hidden="true" />}
loading={mutation.isPending}
disabled={!formValid || mutation.isPending}
>
Générer ma présentation
</Button>
</form>
</main>
</div>
) )
} }

View file

@ -213,94 +213,99 @@ export function RapportPage() {
} }
return ( return (
<main className="mx-auto max-w-3xl space-y-6 px-4 py-6"> <div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
{/* Breadcrumb */} <main className="mx-auto max-w-3xl space-y-6 px-4 py-6">
<nav {/* Breadcrumb */}
aria-label="Fil d'Ariane" <nav
className="flex items-center gap-1.5 text-sm text-ink-secondary" aria-label="Fil d'Ariane"
> className="flex items-center gap-1.5 text-sm text-ink-secondary"
<button
type="button"
onClick={goToSimulations}
className="transition-colors duration-150 hover:text-ink-primary"
> >
Simulations <button
</button> type="button"
<span aria-hidden="true"></span> onClick={goToSimulations}
<span aria-current="page" className="text-ink-primary"> className="transition-colors duration-150 hover:text-ink-primary"
Rapport
</span>
</nav>
{(isLoading || isPlanLoading) && <RapportSkeleton />}
{isInProgress && (
<p className="text-center text-sm text-ink-secondary" aria-live="polite">
Votre simulation est en cours.
</p>
)}
{(isError || isPlanError) && !isInProgress && !isLoading && !isPlanLoading && (
<Card variant="default" className="border-l-4 border-l-danger p-4">
<div role="alert" className="space-y-3">
<p className="text-sm text-danger">
Impossible de charger ce rapport. Réessayez dans quelques instants.
</p>
<Button variant="secondary" size="sm" onClick={() => navigate('/simulation/ee')}>
Retour aux simulations
</Button>
</div>
</Card>
)}
{rapport && planData && (
<>
<ScoreHero score={rapport.score} nclc={rapport.nclc} nclcCible={rapport.nclc_cible} />
<RevelationCards revelation={rapport.revelation} />
<DiagnosticCallout diagnostic={rapport.diagnostic} />
<BlurredSection
visible={isSectionVisible(planData.plan, 'criteres')}
onUpgrade={onUpgrade}
> >
<CriteresSection rapport={rapport} /> Simulations
</BlurredSection> </button>
<span aria-hidden="true"></span>
<span aria-current="page" className="text-ink-primary">
Rapport
</span>
</nav>
<ConseilNclcCallout {(isLoading || isPlanLoading) && <RapportSkeleton />}
conseil={rapport.conseil_nclc}
nclc={rapport.nclc}
nclcCible={rapport.nclc_cible}
/>
<BlurredSection {isInProgress && (
visible={isSectionVisible(planData.plan, 'exercices')} <p className="text-center text-sm text-ink-secondary" aria-live="polite">
onUpgrade={onUpgrade} Votre simulation est en cours.
> </p>
<ExercicesSection )}
rapport={rapport}
hasTimedOut={hasTimedOut} {(isError || isPlanError) && !isInProgress && !isLoading && !isPlanLoading && (
onRetry={() => void refetch()} <Card variant="default" className="border-l-4 border-l-danger p-4">
<div role="alert" className="space-y-3">
<p className="text-sm text-danger">
Impossible de charger ce rapport. Réessayez dans quelques instants.
</p>
<Button variant="secondary" size="sm" onClick={() => navigate('/simulation/ee')}>
Retour aux simulations
</Button>
</div>
</Card>
)}
{rapport && planData && (
<>
<ScoreHero score={rapport.score} nclc={rapport.nclc} nclcCible={rapport.nclc_cible} />
<RevelationCards revelation={rapport.revelation} />
<DiagnosticCallout diagnostic={rapport.diagnostic} />
<BlurredSection
visible={isSectionVisible(planData.plan, 'criteres')}
onUpgrade={onUpgrade}
>
<CriteresSection rapport={rapport} />
</BlurredSection>
<ConseilNclcCallout
conseil={rapport.conseil_nclc}
nclc={rapport.nclc}
nclcCible={rapport.nclc_cible}
/> />
</BlurredSection>
<BlurredSection visible={isSectionVisible(planData.plan, 'modele')} onUpgrade={onUpgrade}> <BlurredSection
<ModeleSection visible={isSectionVisible(planData.plan, 'exercices')}
rapport={rapport} onUpgrade={onUpgrade}
hasTimedOut={hasTimedOut} >
onRetry={() => void refetch()} <ExercicesSection
/> rapport={rapport}
</BlurredSection> hasTimedOut={hasTimedOut}
onRetry={() => void refetch()}
/>
</BlurredSection>
{/* Action de sortie — reset + nouvelle simulation */} <BlurredSection
<div className="flex justify-center pt-4"> visible={isSectionVisible(planData.plan, 'modele')}
<Button variant="primary" onClick={goToSimulations}> onUpgrade={onUpgrade}
Nouvelle simulation >
</Button> <ModeleSection
</div> rapport={rapport}
</> hasTimedOut={hasTimedOut}
)} onRetry={() => void refetch()}
</main> />
</BlurredSection>
{/* Action de sortie — reset + nouvelle simulation */}
<div className="flex justify-center pt-4">
<Button variant="primary" onClick={goToSimulations}>
Nouvelle simulation
</Button>
</div>
</>
)}
</main>
</div>
) )
} }

View file

@ -38,40 +38,42 @@ export function SimulationEOPage() {
const { isCreating, taskUnavailableMessage, selectTask } = useSimulationFlow() const { isCreating, taskUnavailableMessage, selectTask } = useSimulationFlow()
return ( return (
<main className="mx-auto max-w-2xl px-4 py-6"> <div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
{isPlanLoading && <SimulationEOSkeleton />} <main className="mx-auto max-w-2xl px-4 py-6">
{isPlanLoading && <SimulationEOSkeleton />}
{isPlanError && ( {isPlanError && (
<div className="space-y-3 text-center"> <div className="space-y-3 text-center">
<p className="text-sm text-danger"> <p className="text-sm text-danger">
Impossible de charger vos informations. Réessayez dans quelques instants. Impossible de charger vos informations. Réessayez dans quelques instants.
</p> </p>
<Button variant="secondary" size="sm" onClick={() => refetchPlan()}> <Button variant="secondary" size="sm" onClick={() => refetchPlan()}>
Réessayer Réessayer
</Button> </Button>
</div> </div>
)} )}
{planData && ( {planData && (
<div className="space-y-4"> <div className="space-y-4">
<TaskSelector <TaskSelector
type="EO" type="EO"
plan={planData.plan} plan={planData.plan}
simulationsUsed={planData.simulations_used} simulationsUsed={planData.simulations_used}
isLoading={isCreating} isLoading={isCreating}
onSelect={selectTask} onSelect={selectTask}
/> />
{taskUnavailableMessage && ( {taskUnavailableMessage && (
<div <div
role="status" role="status"
className="rounded-lg border border-border bg-surface px-4 py-3 text-sm text-ink-secondary" className="rounded-lg border border-border bg-surface px-4 py-3 text-sm text-ink-secondary"
> >
{taskUnavailableMessage} {taskUnavailableMessage}
</div> </div>
)} )}
</div> </div>
)} )}
</main> </main>
</div>
) )
} }

View file

@ -56,45 +56,47 @@ export function SimulationPage() {
// createMutation.onSuccess (idle → choosing-subject → navigate /sujets). // createMutation.onSuccess (idle → choosing-subject → navigate /sujets).
return ( return (
<main className="mx-auto max-w-2xl px-4 py-6"> <div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
{isPlanLoading && <SimulationSkeleton />} <main className="mx-auto max-w-2xl px-4 py-6">
{isPlanLoading && <SimulationSkeleton />}
{isPlanError && ( {isPlanError && (
<div className="space-y-3 text-center"> <div className="space-y-3 text-center">
<p className="text-sm text-danger"> <p className="text-sm text-danger">
Impossible de charger vos informations. Réessayez dans quelques instants. Impossible de charger vos informations. Réessayez dans quelques instants.
</p> </p>
<Button variant="secondary" size="sm" onClick={() => refetchPlan()}> <Button variant="secondary" size="sm" onClick={() => refetchPlan()}>
Réessayer Réessayer
</Button> </Button>
</div> </div>
)} )}
{planData && step === 'idle' && ( {planData && step === 'idle' && (
<TaskSelector <TaskSelector
type="EE" type="EE"
plan={planData.plan} plan={planData.plan}
simulationsUsed={planData.simulations_used} simulationsUsed={planData.simulations_used}
isLoading={isCreating} isLoading={isCreating}
onSelect={selectTask} onSelect={selectTask}
/> />
)} )}
{planData && (step === 'task-selected' || step === 'correcting') && production && ( {planData && (step === 'task-selected' || step === 'correcting') && production && (
<SimulationForm <SimulationForm
tache={production.tache} tache={production.tache}
sujet={sujet} sujet={sujet}
plan={planData.plan} plan={planData.plan}
simulationId={production.id} simulationId={production.id}
initialContenu={production.contenu} initialContenu={production.contenu}
step={step} step={step}
isSubmitting={isCorrecting} isSubmitting={isCorrecting}
error={correctError} error={correctError}
onSubmit={submitText} onSubmit={submitText}
onBack={reset} onBack={reset}
onChangeSujet={goToSubjectPicker} onChangeSujet={goToSubjectPicker}
/> />
)} )}
</main> </main>
</div>
) )
} }

View file

@ -64,63 +64,69 @@ export function SujetsEOPage() {
const hasSujets = (sujets?.length ?? 0) > 0 const hasSujets = (sujets?.length ?? 0) > 0
return ( return (
<main className="mx-auto max-w-4xl px-4 py-6"> <div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
<div className="mb-4 flex items-center gap-3"> <main className="mx-auto max-w-4xl px-4 py-6">
<button <div className="mb-4 flex items-center gap-3">
type="button" <button
onClick={() => { type="button"
reset() onClick={() => {
navigate('/simulation/eo') reset()
}} navigate('/simulation/eo')
className="text-sm text-ink-secondary underline-offset-4 hover:text-ink-primary hover:underline" }}
> className="text-sm text-ink-secondary underline-offset-4 hover:text-ink-primary hover:underline"
Retour >
</button> Retour
<h2 className="flex-1 text-lg font-semibold text-ink-primary">
Choisir un sujet {formatTache(production.tache)}
</h2>
</div>
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-ink-secondary">
{isLoading
? 'Chargement des sujets…'
: hasSujets
? `${sujets!.length} sujet${sujets!.length > 1 ? 's' : ''} disponible${sujets!.length > 1 ? 's' : ''}.`
: 'Aucun sujet disponible pour cette tâche.'}
</p>
<Button
variant="secondary"
size="sm"
icon={<Shuffle className="size-4" aria-hidden="true" />}
onClick={handleRandom}
disabled={!hasSujets}
>
Sujet aléatoire
</Button>
</div>
{isError && (
<div
role="alert"
className="mb-4 rounded-md border border-danger/40 bg-danger-soft px-3 py-2 text-sm text-danger"
>
Impossible de charger les sujets.{' '}
<button type="button" onClick={() => refetch()} className="underline underline-offset-2">
Réessayer
</button> </button>
<h2 className="flex-1 text-lg font-semibold text-ink-primary">
Choisir un sujet {formatTache(production.tache)}
</h2>
</div> </div>
)}
{isLoading ? ( <div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<SujetsSkeleton /> <p className="text-sm text-ink-secondary">
) : hasSujets ? ( {isLoading
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3"> ? 'Chargement des sujets…'
{sujets!.map((sujet) => ( : hasSujets
<SujetCard key={sujet.id} sujet={sujet} onSelect={handleSelect} /> ? `${sujets!.length} sujet${sujets!.length > 1 ? 's' : ''} disponible${sujets!.length > 1 ? 's' : ''}.`
))} : 'Aucun sujet disponible pour cette tâche.'}
</p>
<Button
variant="secondary"
size="sm"
icon={<Shuffle className="size-4" aria-hidden="true" />}
onClick={handleRandom}
disabled={!hasSujets}
>
Sujet aléatoire
</Button>
</div> </div>
) : null}
</main> {isError && (
<div
role="alert"
className="mb-4 rounded-md border border-danger/40 bg-danger-soft px-3 py-2 text-sm text-danger"
>
Impossible de charger les sujets.{' '}
<button
type="button"
onClick={() => refetch()}
className="underline underline-offset-2"
>
Réessayer
</button>
</div>
)}
{isLoading ? (
<SujetsSkeleton />
) : hasSujets ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{sujets!.map((sujet) => (
<SujetCard key={sujet.id} sujet={sujet} onSelect={handleSelect} />
))}
</div>
) : null}
</main>
</div>
) )
} }