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 }}
>
<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>
{/* ── MOBILE — BottomNav fixe ────────────────────────────────── */}

View file

@ -39,7 +39,7 @@ function DashboardSkeleton() {
)
}
export function DashboardPage() {
function DashboardContent() {
const { user } = useAuth()
const { data, isLoading, isError } = usePlan()
const queryClient = useQueryClient()
@ -88,3 +88,11 @@ export function DashboardPage() {
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
* cas `rapport === null` en redirigeant vers /simulation/ee FTD-21).
* Layout flex : Date · Libellé · Badge NCLC · Score · Chevron.
* Couleur du badge NCLC selon seuil (cf. `nclcChipVariant`).
*
* Règle L : tokens Direction H exclusivement.
* Règle H : purement présentationnel aucune logique plan ici.
* Règle L : tokens DA Charcoal exclusivement.
* Règle H : purement présentationnel.
*/
import { ChevronRight } from 'lucide-react'
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 { formatShortDate, formatTaskLabel, nclcChipVariant } from '../lib/historique'
interface Props {
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 isExam = item.mode === 'examen'
const borderClass = isLast ? '' : 'border-b border-border'
return (
<Link
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">
<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-primary">
{formatTache(item.tache)}
<span className="w-[68px] shrink-0 text-[11.5px] tabular-nums text-ink-tertiary">
{formatShortDate(item.created_at)}
</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 ? (
<div className="shrink-0 text-right">
<p className="tabular-nums text-ink-primary">
<span className="text-xl font-bold">{item.score}</span>
<span className="text-sm font-medium text-ink-secondary">/20</span>
</p>
<p className="text-xs text-ink-secondary tabular-nums">
NCLC {item.nclc}
{item.nclc_cible ? ` / cible ${item.nclc_cible}` : ''}
</p>
</div>
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-ink-primary">
{formatTaskLabel(item)}
</span>
{hasScore && item.nclc !== null ? (
<NclcBadge nclc={item.nclc} />
) : (
<div className="shrink-0 text-right text-xs text-ink-secondary">Score à venir</div>
<span className={`${CHIP_BASE} ${CHIP_NEUTRAL}`}>En cours</span>
)}
</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'}
</span>
<ChevronRight className="size-[14px] shrink-0 text-ink-tertiary" aria-hidden="true" />
</Link>
)
}

View file

@ -1,13 +1,14 @@
/**
* SimulationsList Sprint 3.7.
* SimulationsList refonte Sprint 4.7.
*
* Orchestre :
* - Loading / error / empty state
* - Gating plan : Free aperçu flouté + upgrade ; Standard+ liste
* - Pagination Précédent/Suivant (MVP)
* - Reçoit directement `items: SimulationListItem[]` (filtrés en amont par la
* page) au lieu d'une réponse paginée. La pagination Précédent/Suivant a
* é supprimée au profit du filtrage local (cf. HistoriquePage).
* - 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 Direction H exclusivement.
* Règle L : tokens DA Charcoal exclusivement.
*/
import { Lock } from 'lucide-react'
@ -15,27 +16,24 @@ 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 type { SimulationListItem as Item } from '@/entities/production/types'
import { SimulationListItem } from './SimulationListItem'
interface Props {
plan: Plan
data: SimulationsListResponse | undefined
items: Item[]
isLoading: boolean
isError: boolean
page: number
limit: number
onPrev: () => void
onNext: () => void
/** True si au moins un filtre non-`all` est actif — distingue empty filtré vs global. */
isFiltered: boolean
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-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">
{PLACEHOLDER_WIDTHS.map((w, i) => (
<div key={i} className={`h-16 rounded bg-surface-hover ${w}`} />
@ -56,7 +54,7 @@ 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-surface" />
<div key={i} className="h-14 animate-pulse rounded-lg bg-surface" />
))}
</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() {
return (
<Card variant="default" className="border-l-4 border-l-danger p-4">
@ -90,58 +99,27 @@ function ErrorState() {
)
}
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).
export function SimulationsList({ plan, items, isLoading, isError, isFiltered, onUpgrade }: Props) {
if (!hasAccess(plan, 'dashboard')) {
return <BlurredPreview onUpgrade={onUpgrade} />
}
if (isError) return <ErrorState />
if (isLoading && !data) return <ListSkeleton />
if (!data) return null
if (isLoading) return <ListSkeleton />
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
if (items.length === 0) {
return isFiltered ? <EmptyFilteredState /> : <EmptyState />
}
return (
<div className="space-y-3">
<ul className="space-y-3">
{data.data.map((item) => (
<li key={item.id}>
<SimulationListItem item={item} />
<div className="overflow-hidden rounded-[12px] border border-border bg-surface shadow-card">
<ul>
{items.map((it, i) => (
<li key={it.id}>
<SimulationListItem item={it} isLast={i === items.length - 1} />
</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-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>
)
}

View file

@ -1,12 +1,8 @@
/**
* Tests SimulationsList (Sprint 3.7).
* Tests SimulationsList refonte Sprint 4.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é
* Couvre : gating Free, état vide global, état vide filtré, items rendus,
* isError, isLoading. La pagination a é retirée au Sprint 4.7.
*/
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 { MemoryRouter } from 'react-router-dom'
import { SimulationsList } from '../SimulationsList'
import type { SimulationsListResponse } from '@/entities/production/types'
import type { SimulationListItem } from '@/entities/production/types'
afterEach(cleanup)
@ -22,13 +18,7 @@ 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: [
const ITEMS: SimulationListItem[] = [
{
id: 'p1',
tache: 'EE_T1',
@ -56,32 +46,24 @@ const THREE_ITEMS: SimulationsListResponse = {
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"', () => {
describe('SimulationsList — gating Free', () => {
it("affiche l'aperçu flouté pour le plan Free", () => {
renderWithRouter(
<SimulationsList
plan="free"
data={THREE_ITEMS}
items={ITEMS}
isLoading={false}
isError={false}
page={1}
limit={20}
onPrev={NOOP}
onNext={NOOP}
isFiltered={false}
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()
expect(screen.queryByText(/EE · Tâche 1/i)).not.toBeInTheDocument()
})
it('clic sur "Passer en Standard" appelle onUpgrade', async () => {
@ -90,221 +72,115 @@ describe('SimulationsList — plan Free (gating)', () => {
renderWithRouter(
<SimulationsList
plan="free"
data={THREE_ITEMS}
items={ITEMS}
isLoading={false}
isError={false}
page={1}
limit={20}
onPrev={NOOP}
onNext={NOOP}
isFiltered={false}
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"', () => {
it('affiche l\'état vide global avec CTA "Démarrer une simulation"', () => {
renderWithRouter(
<SimulationsList
plan="standard"
data={EMPTY}
items={[]}
isLoading={false}
isError={false}
page={1}
limit={20}
onPrev={NOOP}
onNext={NOOP}
isFiltered={false}
onUpgrade={NOOP}
/>,
)
expect(screen.getByText(/aucune simulation/i)).toBeInTheDocument()
expect(screen.getByText(/aucune simulation pour le moment/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', () => {
it('affiche un état vide spécifique quand des filtres sont actifs', () => {
renderWithRouter(
<SimulationsList
plan="standard"
data={THREE_ITEMS}
items={[]}
isLoading={false}
isError={false}
page={1}
limit={20}
onPrev={NOOP}
onNext={NOOP}
isFiltered={true}
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')
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()
expect(screen.getByText('EE · Tâche 1')).toBeInTheDocument()
expect(screen.getByText('Examen blanc EE')).toBeInTheDocument()
expect(screen.getByText('14/20')).toBeInTheDocument()
expect(screen.getByText('NCLC 9')).toBeInTheDocument()
expect(screen.getByText(/En cours/i)).toBeInTheDocument()
expect(screen.getByText('—/20')).toBeInTheDocument()
})
it('chaque item pointe vers /rapport/:id', () => {
renderWithRouter(
<SimulationsList
plan="standard"
data={THREE_ITEMS}
items={ITEMS}
isLoading={false}
isError={false}
page={1}
limit={20}
onPrev={NOOP}
onNext={NOOP}
isFiltered={false}
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()
const link = screen.getAllByRole('link')[0]
expect(link).toHaveAttribute('href', '/rapport/p1')
})
})
describe('SimulationsList — états transverses', () => {
it("isError → affiche le callout d'erreur", () => {
it("isError → callout d'erreur", () => {
renderWithRouter(
<SimulationsList
plan="standard"
data={undefined}
items={[]}
isLoading={false}
isError={true}
page={1}
limit={20}
onPrev={NOOP}
onNext={NOOP}
isFiltered={false}
onUpgrade={NOOP}
/>,
)
expect(screen.getByRole('alert')).toHaveTextContent(/impossible de charger/i)
})
it('isLoading + pas de data → squelettes', () => {
it('isLoading → squelettes', () => {
renderWithRouter(
<SimulationsList
plan="standard"
data={undefined}
items={[]}
isLoading={true}
isError={false}
page={1}
limit={20}
onPrev={NOOP}
onNext={NOOP}
isFiltered={false}
onUpgrade={NOOP}
/>,
)
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 :
* - `usePlan` (cache partagé avec dashboard/simulation)
* - `useSimulationsList(page, limit)` cache `['simulations', 'list', p, l]`
* Charge en une fois les 50 simulations les plus récentes via
* `useSimulationsList(1, 50)` puis applique les filtres (tâche + période)
* 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`.
* Pagination : Précédent/Suivant via state local `page`.
*
* Règle H : aucune logique métier toute l'orchestration vit dans SimulationsList.
* Règle D : gating via `hasAccess(plan, 'dashboard')` dans `SimulationsList`.
* Règle L : tokens DA Charcoal exclusivement.
*/
import { useState } from 'react'
import { useMemo, 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'
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() {
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, 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 (
<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-primary">Historique</h1>
<p className="text-sm text-ink-secondary">
Retrouvez toutes vos simulations passées et leur progression.
<main className="mx-auto w-full max-w-[860px]">
<header className="mb-6 flex flex-wrap items-end justify-between gap-4">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.073em] text-ink-tertiary">
Historique
</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>
{showStats && canSeeContent && (
<div className="mb-6">
<HistoriqueStatsCards stats={stats} trend={trend} />
</div>
)}
{isPlanLoading || !planData ? (
<div className="space-y-3" aria-busy="true">
<div className="h-20 animate-pulse rounded-lg bg-surface" />
@ -43,13 +91,10 @@ export function HistoriquePage() {
) : (
<SimulationsList
plan={planData.plan}
data={data}
items={filtered}
isLoading={isLoading}
isError={isError}
page={page}
limit={PAGE_SIZE}
onPrev={() => setPage((p) => Math.max(1, p - 1))}
onNext={() => setPage((p) => p + 1)}
isFiltered={isFiltered}
onUpgrade={() => navigate('/plan')}
/>
)}

View file

@ -36,6 +36,7 @@ export function ProgressionPage() {
const isPremium = planData ? hasAccess(planData.plan, 'pattern_analysis') : false
return (
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
<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-primary">Profil de préparation</h1>
@ -56,7 +57,8 @@ export function ProgressionPage() {
{isError && (
<Card variant="default" className="border-l-4 border-l-danger p-4">
<p className="text-sm text-danger" role="alert">
Impossible de charger votre profil de préparation. Réessayez dans quelques instants.
Impossible de charger votre profil de préparation. Réessayez dans quelques
instants.
</p>
<div className="mt-3">
<Button variant="secondary" size="sm" onClick={() => navigate(0)}>
@ -69,5 +71,6 @@ export function ProgressionPage() {
</>
)}
</main>
</div>
)
}

View file

@ -91,9 +91,12 @@ export function EnregistrementEOPage() {
const lockControls = submitting || isCorrecting
return (
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
<main className="mx-auto max-w-3xl px-4 py-6">
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<h2 className="text-lg font-semibold text-ink-primary">{formatTache(production.tache)}</h2>
<h2 className="text-lg font-semibold text-ink-primary">
{formatTache(production.tache)}
</h2>
<Badge variant="neutral" className="inline-flex items-center gap-1.5">
<Timer className="size-3.5" aria-hidden="true" />
Durée recommandée : {formatTimer(dureeRecommandee)}
@ -167,5 +170,6 @@ export function EnregistrementEOPage() {
</div>
)}
</main>
</div>
)
}

View file

@ -29,6 +29,7 @@ export function ModeChoixT1Page() {
if (shouldRedirect) return null
return (
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
<main className="mx-auto max-w-3xl px-4 py-6">
<div className="mb-2 flex items-center gap-3">
<button
@ -43,7 +44,9 @@ export function ModeChoixT1Page() {
</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">
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">
@ -74,5 +77,6 @@ export function ModeChoixT1Page() {
</Card>
</div>
</main>
</div>
)
}

View file

@ -51,6 +51,7 @@ export function PreEnregistrementEOPage() {
}
return (
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
<main className="mx-auto max-w-3xl px-4 py-6">
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<h2 className="text-lg font-semibold text-ink-primary">{heading}</h2>
@ -69,7 +70,9 @@ export function PreEnregistrementEOPage() {
<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>
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 && (
@ -101,5 +104,6 @@ export function PreEnregistrementEOPage() {
)}
</div>
</main>
</div>
)
}

View file

@ -108,6 +108,7 @@ export function PresentationGenereeT1Page() {
}
return (
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
<main className="mx-auto max-w-3xl px-4 py-6">
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
@ -168,8 +169,8 @@ export function PresentationGenereeT1Page() {
role="note"
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.
<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">
@ -194,5 +195,6 @@ export function PresentationGenereeT1Page() {
Je suis prêt Enregistrer
</Button>
</main>
</div>
)
}

View file

@ -158,6 +158,7 @@ export function QuestionnaireT1Page() {
if (shouldRedirect) return null
return (
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
<main className="mx-auto max-w-2xl px-4 py-6">
<div className="mb-2 flex items-center gap-3">
<button
@ -239,5 +240,6 @@ export function QuestionnaireT1Page() {
</Button>
</form>
</main>
</div>
)
}

View file

@ -213,6 +213,7 @@ export function RapportPage() {
}
return (
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
<main className="mx-auto max-w-3xl space-y-6 px-4 py-6">
{/* Breadcrumb */}
<nav
@ -285,7 +286,10 @@ export function RapportPage() {
/>
</BlurredSection>
<BlurredSection visible={isSectionVisible(planData.plan, 'modele')} onUpgrade={onUpgrade}>
<BlurredSection
visible={isSectionVisible(planData.plan, 'modele')}
onUpgrade={onUpgrade}
>
<ModeleSection
rapport={rapport}
hasTimedOut={hasTimedOut}
@ -302,5 +306,6 @@ export function RapportPage() {
</>
)}
</main>
</div>
)
}

View file

@ -38,6 +38,7 @@ export function SimulationEOPage() {
const { isCreating, taskUnavailableMessage, selectTask } = useSimulationFlow()
return (
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
<main className="mx-auto max-w-2xl px-4 py-6">
{isPlanLoading && <SimulationEOSkeleton />}
@ -73,5 +74,6 @@ export function SimulationEOPage() {
</div>
)}
</main>
</div>
)
}

View file

@ -56,6 +56,7 @@ export function SimulationPage() {
// createMutation.onSuccess (idle → choosing-subject → navigate /sujets).
return (
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
<main className="mx-auto max-w-2xl px-4 py-6">
{isPlanLoading && <SimulationSkeleton />}
@ -96,5 +97,6 @@ export function SimulationPage() {
/>
)}
</main>
</div>
)
}

View file

@ -64,6 +64,7 @@ export function SujetsEOPage() {
const hasSujets = (sujets?.length ?? 0) > 0
return (
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
<main className="mx-auto max-w-4xl px-4 py-6">
<div className="mb-4 flex items-center gap-3">
<button
@ -106,7 +107,11 @@ export function SujetsEOPage() {
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">
<button
type="button"
onClick={() => refetch()}
className="underline underline-offset-2"
>
Réessayer
</button>
</div>
@ -122,5 +127,6 @@ export function SujetsEOPage() {
</div>
) : null}
</main>
</div>
)
}